Module 5: Introduction to LangGraph

Project: Chatbot with State and Conditional Routing

Project overview

In the previous seven capsules you learned to build workflows as graphs with LangGraph: StateGraph to define the structure, nodes as functions that transform state, edges and conditional edges to control the flow, typed state with TypedDict and Annotated with reducers, compilation and execution, and the fundamental decision of when to use create_agent vs StateGraph. You saw each concept individually, with isolated examples. Now you're going to combine all of it into a real system.

This project is a chatbot that doesn't treat every question the same. When a user types "What is machine learning?", the chatbot detects that it's a Q&A question and routes it to a node specialized in giving clear explanations. When they type "Write me a poem about the rain", it detects creative intent and sends it to a node optimized for creative writing. When they type "How do I write a for loop in Python", it sends it to the code node. Each node has its own prompt and response style. The routing is automatic, based on conditional edges that evaluate the intent classification.

This is your last standalone mini-project. Starting in Module 6, everything you build will be part of the evolving "AI Research Assistant" project — a system that grows module by module from a simple agent into a production-ready multi-agent system. What you build today is the conceptual foundation of that evolution: understanding how to design graphs with specialized nodes, conditional routing, and typed state is exactly what you need to build more complex systems.

The end result is an interactive terminal chatbot with graph visualization, intent classification visible on every turn, and specialized responses by domain.


Project goal

Build a chatbot with LangGraph that classifies the user's intent and routes to specialized nodes using conditional edges, with typed state and graph visualization.

By completing this project:

  • 🔧 You'll know how to design typed state with TypedDict for a multi-domain chatbot
  • 🔧 You'll implement a classifier node that uses the LLM to detect intent
  • 🔧 You'll create specialized nodes with domain-optimized prompts
  • 🔧 You'll configure conditional edges for dynamic routing based on classification
  • 🔧 You'll visualize the complete graph with draw_mermaid_png
  • 🔧 You'll build a multi-turn conversation loop with persistent state

Technical specifications

Tech stack

ComponentVersionPurpose
Python3.11+Runtime
LangChainv1.2+LLM framework
LangGraphv1.0+StateGraph, nodes, edges
langchain-openailatestModel provider
python-dotenvlatestEnvironment variables

Initial setup

pip install langchain langgraph langchain-openai python-dotenv

Create a .env file at the root of your project:

# .env
OPENAI_API_KEY=sk-...

Project structure

routing-chatbot/
├── .env                    # API key
├── routing_chatbot.py      # Main code (everything in one file)
└── requirements.txt        # Dependencies
# requirements.txt
langchain>=0.3.0
langgraph>=0.3.0
langchain-openai>=0.3.0
python-dotenv>=1.0.0

Step 1: Design the state

State is the heart of the graph. It defines what information flows between nodes. For a chatbot with routing, you need: the conversation messages, the classified intent, and a counter for tracking.

import operator
from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages

class ChatState(TypedDict):
    messages: Annotated[list, add_messages]
    intent: str
    response_count: int

Three fields, each with a specific purpose:

  • messages: the full conversation. Use add_messages as the reducer so each node appends messages without overwriting the previous ones. This is the standard pattern for chatbots in LangGraph
  • intent: the classification of the last question ("qa", "creative", "code"). The classifier node assigns it and the conditional edges read it to decide the routing
  • response_count: how many responses the chatbot has generated in the session. Useful for tracking and for capping conversations

Step 2: Create the classifier node

The classifier is the first node that processes every user message. It uses the LLM to analyze the intent and assign a category. The classification determines which specialized node the message gets sent to.

from langchain.chat_models import init_chat_model

model = init_chat_model("openai:gpt-4.1-mini", temperature=0)

def classifier_node(state: ChatState) -> dict:
    """Classifies the intent of the user's last message."""
    last_message = state["messages"][-1]

    classification_prompt = f"""Classify the intent of the following message into exactly ONE of these categories:

- qa: Factual questions, explanations, definitions, "what is", "how does it work", informational queries
- creative: Creative writing, poems, stories, songs, artistic content, "write me", "make up"
- code: Programming questions, code, debugging, implementation, "how do I", "code for"

User message: {last_message.content}

Reply ONLY with the category (qa, creative, or code). Nothing else."""

    response = model.invoke(classification_prompt)
    intent = response.content.strip().lower()

    if intent not in ("qa", "creative", "code"):
        intent = "qa"

    print(f"  🏷️  Classified intent: {intent}")
    return {"intent": intent}

The fallback to "qa" matters: if the model returns something unexpected (which can happen with smaller models), the system doesn't break — it simply treats the question as general Q&A.


Step 3: Create the specialized nodes

Each node has a prompt designed for its domain. It's not the same model answering everything the same way — each node generates responses with the style and depth appropriate for its type of content.

Q&A node

def qa_node(state: ChatState) -> dict:
    """Generates informative, educational responses."""
    last_message = state["messages"][-1]

    qa_prompt = f"""You are an expert at explaining concepts clearly and precisely.

Instructions:
- Answer in an informative, educational way
- Use concrete examples when they help
- Structure your answer clearly
- If you don't know something, say so honestly

User question: {last_message.content}"""

    response = model.invoke(qa_prompt)
    count = state.get("response_count", 0) + 1
    print(f"  📚 Q&A answered ({count} total responses)")

    return {
        "messages": [("assistant", response.content)],
        "response_count": count,
    }

Creative node

def creative_node(state: ChatState) -> dict:
    """Generates creative content: poems, stories, songs."""
    last_message = state["messages"][-1]

    creative_prompt = f"""You are a talented creative writer with an expressive, evocative style.

Instructions:
- Generate original, artistic content
- Use rich language, metaphors, and rhythm when appropriate
- Adapt the format to the type of content (poem, story, song, etc.)
- Be creative but coherent

User request: {last_message.content}"""

    response = model.invoke(creative_prompt)
    count = state.get("response_count", 0) + 1
    print(f"  🎨 Creative answered ({count} total responses)")

    return {
        "messages": [("assistant", response.content)],
        "response_count": count,
    }

Code node

def code_node(state: ChatState) -> dict:
    """Generates code and technical programming explanations."""
    last_message = state["messages"][-1]

    code_prompt = f"""You are a senior programmer, expert in multiple languages.

Instructions:
- Generate clean, working, well-commented code
- Include the necessary imports
- Briefly explain what the code does
- If there are several ways to solve the problem, show the most pythonic/idiomatic one
- Include a usage example when relevant

User request: {last_message.content}"""

    response = model.invoke(code_prompt)
    count = state.get("response_count", 0) + 1
    print(f"  💻 Code answered ({count} total responses)")

    return {
        "messages": [("assistant", response.content)],
        "response_count": count,
    }

Each node returns a dictionary with messages (the response as an assistant message, which gets appended to the conversation thanks to the add_messages reducer) and response_count (the incremented counter).


Step 4: Create the routing function and conditional edges

The routing function reads the intent from the state and returns the name of the node execution should go to. The conditional edges use this function to decide the path.

def route_by_intent(state: ChatState) -> str:
    """Routes to the specialized node based on the classified intent."""
    intent = state.get("intent", "qa")
    route_map = {
        "qa": "qa_node",
        "creative": "creative_node",
        "code": "code_node",
    }
    destination = route_map.get(intent, "qa_node")
    print(f"  🔀 Routing → {destination}")
    return destination

This function is what keeps the graph from being linear. Instead of classifier → qa_node → END, you have classifier → (qa_node | creative_node | code_node) → END. The conditional edge runs route_by_intent at runtime and decides the path.


Step 5: Compile, visualize, and run

Now let's assemble everything into a StateGraph, compile it, and visualize it.

from langgraph.graph import StateGraph, START, END

graph = StateGraph(ChatState)

graph.add_node("classifier", classifier_node)
graph.add_node("qa_node", qa_node)
graph.add_node("creative_node", creative_node)
graph.add_node("code_node", code_node)

graph.add_edge(START, "classifier")
graph.add_conditional_edges("classifier", route_by_intent)
graph.add_edge("qa_node", END)
graph.add_edge("creative_node", END)
graph.add_edge("code_node", END)

app = graph.compile()

Visualize the graph

from IPython.display import Image, display

img_data = app.get_graph().draw_mermaid_png()
with open("routing_chatbot_graph.png", "wb") as f:
    f.write(img_data)
print("Graph saved to routing_chatbot_graph.png")

The visualized graph shows:

        ┌──────────┐
        │  START    │
        └────┬─────┘
             │
        ┌────▼─────┐
        │classifier │
        └────┬─────┘
             │ (conditional)
    ┌────────┼────────┐
    │        │        │
┌───▼──┐ ┌──▼───┐ ┌──▼──┐
│qa_node│ │creative│ │code │
│       │ │_node  │ │_node│
└───┬──┘ └──┬───┘ └──┬──┘
    │        │        │
    └────────┼────────┘
             │
        ┌────▼─────┐
        │   END     │
        └──────────┘

Step 6: Add multi-turn conversation

To turn this into a real chatbot, we add an interactive loop that keeps state between turns. Each user question goes through the classifier and gets routed to the right node, but the previous messages are preserved in the state.

from langchain_core.messages import HumanMessage

def chat_loop():
    """Interactive multi-turn conversation loop."""
    print("=" * 60)
    print("  🤖 Chatbot with Conditional Routing")
    print("  Ask Q&A, creative, or code questions.")
    print("  The chatbot detects the intent and responds.")
    print("  Commands: 'stats' (metrics), 'exit' (quit)")
    print("=" * 60)

    conversation_state = {
        "messages": [],
        "intent": "",
        "response_count": 0,
    }

    while True:
        try:
            user_input = input("\n💬 You: ").strip()
        except (KeyboardInterrupt, EOFError):
            print("\n\nSee you later!")
            break

        if not user_input:
            continue

        if user_input.lower() in ("exit", "quit"):
            print(f"\n📊 Session finished:")
            print(f"   Responses generated: {conversation_state['response_count']}")
            print(f"   Messages in conversation: {len(conversation_state['messages'])}")
            print("\nSee you later!")
            break

        if user_input.lower() == "stats":
            msg_count = len(conversation_state["messages"])
            resp_count = conversation_state["response_count"]
            last_intent = conversation_state.get("intent", "none")
            print(f"\n📊 Current state:")
            print(f"   Messages: {msg_count}")
            print(f"   Responses: {resp_count}")
            print(f"   Last intent: {last_intent}")
            continue

        print(f"\n{'─' * 60}")

        conversation_state["messages"].append(
            HumanMessage(content=user_input)
        )

        try:
            result = app.invoke(conversation_state)
            conversation_state = result

            assistant_message = result["messages"][-1]
            print(f"{'─' * 60}")
            print(f"\n🤖 Chatbot [{result['intent'].upper()}]:\n")
            print(assistant_message.content)
            print(f"\n{'─' * 60}")

        except Exception as e:
            print(f"\n❌ Error: {e}")
            print("   Try another question.")

The conversation_state is kept between turns. Every time the user types something, we append their message to the message list, invoke the graph, and update the state with the result. The full conversation history is available to the specialized nodes.


Complete code

This is the complete routing_chatbot.py file. Copy it and run it directly.

"""
Chatbot with State and Conditional Routing
Module 5 — LangChain & LangGraph: From Chains to Agents

Classifies the user's intent and routes to specialized nodes.
"""

from dotenv import load_dotenv
load_dotenv()

import operator
from typing import TypedDict, Annotated

from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages


# =============================================================================
# STATE
# =============================================================================

class ChatState(TypedDict):
    messages: Annotated[list, add_messages]
    intent: str
    response_count: int


# =============================================================================
# MODEL
# =============================================================================

model = init_chat_model("openai:gpt-4.1-mini", temperature=0)


# =============================================================================
# NODES
# =============================================================================

def classifier_node(state: ChatState) -> dict:
    """Classifies the intent of the user's last message."""
    last_message = state["messages"][-1]

    classification_prompt = f"""Classify the intent of the following message into exactly ONE of these categories:

- qa: Factual questions, explanations, definitions, "what is", "how does it work", informational queries
- creative: Creative writing, poems, stories, songs, artistic content, "write me", "make up"
- code: Programming questions, code, debugging, implementation, "how do I", "code for"

User message: {last_message.content}

Reply ONLY with the category (qa, creative, or code). Nothing else."""

    response = model.invoke(classification_prompt)
    intent = response.content.strip().lower()

    if intent not in ("qa", "creative", "code"):
        intent = "qa"

    print(f"  🏷️  Classified intent: {intent}")
    return {"intent": intent}


def qa_node(state: ChatState) -> dict:
    """Generates informative, educational responses."""
    last_message = state["messages"][-1]

    qa_prompt = f"""You are an expert at explaining concepts clearly and precisely.

Instructions:
- Answer in an informative, educational way
- Use concrete examples when they help
- Structure your answer clearly
- If you don't know something, say so honestly

User question: {last_message.content}"""

    response = model.invoke(qa_prompt)
    count = state.get("response_count", 0) + 1
    print(f"  📚 Q&A answered ({count} total responses)")

    return {
        "messages": [("assistant", response.content)],
        "response_count": count,
    }


def creative_node(state: ChatState) -> dict:
    """Generates creative content: poems, stories, songs."""
    last_message = state["messages"][-1]

    creative_prompt = f"""You are a talented creative writer with an expressive, evocative style.

Instructions:
- Generate original, artistic content
- Use rich language, metaphors, and rhythm when appropriate
- Adapt the format to the type of content (poem, story, song, etc.)
- Be creative but coherent

User request: {last_message.content}"""

    response = model.invoke(creative_prompt)
    count = state.get("response_count", 0) + 1
    print(f"  🎨 Creative answered ({count} total responses)")

    return {
        "messages": [("assistant", response.content)],
        "response_count": count,
    }


def code_node(state: ChatState) -> dict:
    """Generates code and technical programming explanations."""
    last_message = state["messages"][-1]

    code_prompt = f"""You are a senior programmer, expert in multiple languages.

Instructions:
- Generate clean, working, well-commented code
- Include the necessary imports
- Briefly explain what the code does
- If there are several ways to solve the problem, show the most pythonic/idiomatic one
- Include a usage example when relevant

User request: {last_message.content}"""

    response = model.invoke(code_prompt)
    count = state.get("response_count", 0) + 1
    print(f"  💻 Code answered ({count} total responses)")

    return {
        "messages": [("assistant", response.content)],
        "response_count": count,
    }


# =============================================================================
# ROUTING
# =============================================================================

def route_by_intent(state: ChatState) -> str:
    """Routes to the specialized node based on the classified intent."""
    intent = state.get("intent", "qa")
    route_map = {
        "qa": "qa_node",
        "creative": "creative_node",
        "code": "code_node",
    }
    destination = route_map.get(intent, "qa_node")
    print(f"  🔀 Routing → {destination}")
    return destination


# =============================================================================
# GRAPH ASSEMBLY
# =============================================================================

graph = StateGraph(ChatState)

graph.add_node("classifier", classifier_node)
graph.add_node("qa_node", qa_node)
graph.add_node("creative_node", creative_node)
graph.add_node("code_node", code_node)

graph.add_edge(START, "classifier")
graph.add_conditional_edges("classifier", route_by_intent)
graph.add_edge("qa_node", END)
graph.add_edge("creative_node", END)
graph.add_edge("code_node", END)

app = graph.compile()


# =============================================================================
# GRAPH VISUALIZATION
# =============================================================================

def save_graph_image():
    """Saves the graph visualization as a PNG."""
    try:
        img_data = app.get_graph().draw_mermaid_png()
        with open("routing_chatbot_graph.png", "wb") as f:
            f.write(img_data)
        print("📊 Graph saved to routing_chatbot_graph.png")
    except Exception as e:
        print(f"⚠️  Couldn't generate the graph image: {e}")
        print("   You can see the graph in Mermaid format:")
        print(app.get_graph().draw_mermaid())


# =============================================================================
# INTERACTIVE CHAT LOOP
# =============================================================================

def chat_loop():
    """Interactive multi-turn conversation loop."""
    print("=" * 60)
    print("  🤖 Chatbot with Conditional Routing")
    print("  Ask Q&A, creative, or code questions.")
    print("  The chatbot detects the intent and responds.")
    print("  Commands: 'stats' | 'graph' | 'exit'")
    print("=" * 60)

    conversation_state = {
        "messages": [],
        "intent": "",
        "response_count": 0,
    }

    while True:
        try:
            user_input = input("\n💬 You: ").strip()
        except (KeyboardInterrupt, EOFError):
            print("\n\nSee you later!")
            break

        if not user_input:
            continue

        if user_input.lower() in ("exit", "quit"):
            resp_count = conversation_state["response_count"]
            msg_count = len(conversation_state["messages"])
            print(f"\n📊 Session finished:")
            print(f"   Responses generated: {resp_count}")
            print(f"   Messages in conversation: {msg_count}")
            print("\nSee you later!")
            break

        if user_input.lower() == "graph":
            save_graph_image()
            continue

        if user_input.lower() == "stats":
            msg_count = len(conversation_state["messages"])
            resp_count = conversation_state["response_count"]
            last_intent = conversation_state.get("intent", "none")
            print(f"\n📊 Current state:")
            print(f"   Messages: {msg_count}")
            print(f"   Responses: {resp_count}")
            print(f"   Last intent: {last_intent}")
            continue

        print(f"\n{'─' * 60}")

        conversation_state["messages"].append(
            HumanMessage(content=user_input)
        )

        try:
            result = app.invoke(conversation_state)
            conversation_state = result

            assistant_message = result["messages"][-1]
            print(f"{'─' * 60}")
            print(f"\n🤖 Chatbot [{result['intent'].upper()}]:\n")
            print(assistant_message.content)
            print(f"\n{'─' * 60}")

        except Exception as e:
            print(f"\n❌ Error: {e}")
            print("   Try another question.")


if __name__ == "__main__":
    chat_loop()

Run it:

python routing_chatbot.py

Success criteria

Your project is complete when you meet all four criteria:

  • Classification routes correctly to the specialized node — Q&A questions go to qa_node, creative ones to creative_node, code ones to code_node
  • Each node generates a response appropriate to its domain — Q&A answers informatively, creative answers artistically, code answers with working code
  • State is preserved across conversation turns — you can ask several questions and the response counter increments, the messages accumulate
  • The graph visualizes correctlydraw_mermaid_png generates an image with the classifier, 3 specialized nodes, and conditional edges

Test scenarios

Test 1: Q&A question (should go to qa_node)

💬 You: What is photosynthesis?

──────────────────────────────────────────────────────────────
  🏷️  Classified intent: qa
  🔀 Routing → qa_node
  📚 Q&A answered (1 total responses)
──────────────────────────────────────────────────────────────

🤖 Chatbot [QA]:

Photosynthesis is the biochemical process by which plants, algae, and
some bacteria convert the sun's light energy into chemical
energy...

Test 2: Creative request (should go to creative_node)

💬 You: Write me a haiku about programming

──────────────────────────────────────────────────────────────
  🏷️  Classified intent: creative
  🔀 Routing → creative_node
  🎨 Creative answered (2 total responses)
──────────────────────────────────────────────────────────────

🤖 Chatbot [CREATIVE]:

Lines of code at rest,
the cursor blinks, thinking hard...
a bug at compile.

Test 3: Code question (should go to code_node)

💬 You: How do I write a recursive fibonacci function in Python

──────────────────────────────────────────────────────────────
  🏷️  Classified intent: code
  🔀 Routing → code_node
  💻 Code answered (3 total responses)
──────────────────────────────────────────────────────────────

🤖 Chatbot [CODE]:

Here's a recursive implementation of Fibonacci:

```python
def fibonacci(n: int) -> int:
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

# Usage example
for i in range(10):
    print(f"F({i}) = {fibonacci(i)}")
```​

To improve performance, you can use memoization...

Test 4: Verify persistent state

💬 You: stats

📊 Current state:
   Messages: 6
   Responses: 3
   Last intent: code

The state shows 6 messages (3 from the user + 3 from the assistant) and 3 responses generated, confirming that state persists across turns.


Common errors

1. ModuleNotFoundError: No module named 'langgraph'

Cause: You didn't install the dependencies.

pip install langchain langgraph langchain-openai python-dotenv

2. The classifier always returns "qa"

Cause: The classification prompt isn't specific enough, or the model returns the category with extra text (e.g., "The category is: qa") and strip doesn't clean it up.

Solution: The fallback to "qa" is designed as a safety net, but if it happens too often, check that the model is returning only the word. You can add more robust validation:

intent = response.content.strip().lower()
for valid in ("qa", "creative", "code"):
    if valid in intent:
        intent = valid
        break
else:
    intent = "qa"

3. KeyError: 'messages' when invoking the graph

Cause: The initial state doesn't include the messages key, or it's malformed.

Solution: Make sure the initial state always has all three keys:

conversation_state = {
    "messages": [],      # empty list, not None
    "intent": "",        # empty string, not None
    "response_count": 0, # 0, not None
}

4. Messages get duplicated on every turn

Cause: You're passing the same messages to the graph without updating the state with the result. The add_messages reducer appends, it doesn't replace.

Solution: After each app.invoke(), update conversation_state with the complete result:

result = app.invoke(conversation_state)
conversation_state = result  # ← replaces the entire state

5. draw_mermaid_png fails with a rendering error

Cause: Generating the PNG requires access to the Mermaid API (internet) or the local pyppeteer library.

Solution: If the image doesn't generate, use the text format:

print(app.get_graph().draw_mermaid())

This prints the diagram in Mermaid format, which you can copy and paste into mermaid.live to visualize it.

6. The creative node answers generically, not artistically

Cause: The creative node's prompt isn't directive enough. By default the model tends to answer informatively.

Solution: Make the prompt more explicit. Add style examples or instructions like "Reply ONLY with the creative content, no explanations or disclaimers."

7. response_count doesn't increment correctly

Cause: If state.get("response_count", 0) returns None instead of 0, the addition fails. This can happen if at some point you assign None to the field.

Solution: Use the default value defensively:

count = (state.get("response_count") or 0) + 1

8. The chatbot loses context between turns

Cause: You're creating a new conversation_state on every iteration of the loop, instead of reusing the state the graph updated.

Solution: Initialize conversation_state once, outside the loop, and update it with the result of each app.invoke(). The complete code already does this correctly.


Ideas to extend it

If you finished the project and want to go further:

  • More categories — Add intents like "math" (calculations), "translation", or "summary" (text summarization). Each with its own specialized node and prompt. Update the classifier and the conditional edges
  • Classification with confidence — Modify the classifier so it also returns a confidence score. If confidence is low (<0.7), route to a node that asks the user for clarification instead of answering directly
  • Fallback node — Add a fallback_node for when the classification isn't clear. This node can ask the user to rephrase their question or pick the category manually
  • Streaming — Swap app.invoke() for app.stream() to watch the responses generate in real time. Process the chunks with stream_mode="values" or stream_mode="updates"
  • Memory with MemorySaver — Add MemorySaver as a checkpointer so the chatbot remembers previous conversations, even if you close it and open it again. You'll learn this in Module 8, but you can get a head start
  • Metrics by intent — Track how many times each intent gets classified during the session. Show a breakdown in the stats command (e.g., "qa: 5, creative: 2, code: 3")
  • Routing evaluation — Create a list of 20 questions with their expected intent and run the classifier against all of them. Compute accuracy and identify the kinds of questions the classifier gets wrong

Connection to the next module

In this project you built a chatbot with conditional routing using StateGraph — specialized nodes, conditional edges, typed state, and graph visualization. This is exactly the kind of system LangGraph was designed to build.

In Module 6: Functional API, you'll learn another way to build the same kind of workflows. Instead of defining explicit graphs with StateGraph, add_node, and add_edge, you'll use plain Python functions with @entrypoint and @task. The conditional routing you did with conditional edges becomes a simple if/else. The loops you'd build with cyclic edges become standard while loops. Same power, expressed in Python control flow.

And something important: starting in Module 6, the evolving project begins. No more standalone mini-projects — everything you build from now on is part of the "AI Research Assistant," a system that grows with you module by module until it becomes a production-ready multi-agent system in Module 12.


Project resources

  1. LangGraph StateGraph Tutorial — Official tutorial for building chatbots with StateGraph
  2. LangGraph Conditional Edges — Guide to branching and conditional routing
  3. LangGraph State Management — Typed state and reducer concepts
  4. LangGraph Visualization — How to visualize graphs with draw_mermaid_png
  5. LangGraph MessagesState — The add_messages reducer for conversations
  6. LangChain init_chat_model — Multi-provider model initialization

Module 5 — LangChain & LangGraph: From Chains to Agents