← Back to DevBytes

Building a Customer Support Bot with LangGraph: Complete Guide

What is LangGraph and Why Use It for Customer Support?

LangGraph is a framework from LangChain that lets you build stateful, multi-actor applications with LLMs. Unlike a simple chain that follows a single path, LangGraph models your application as a directed graph — with nodes representing steps in the workflow and edges defining how information flows between them. For customer support, this is transformative. A support bot needs to classify intents, retrieve knowledge, decide when to escalate, maintain conversation context, and possibly loop back for clarification — all of which map naturally to a graph-based architecture.

Traditional sequential chains struggle with branching logic and state management. LangGraph gives you:

In this tutorial, you'll build a complete customer support bot that classifies user intent, retrieves relevant knowledge, generates empathetic responses, and escalates to a human when needed — all backed by a LangGraph graph that you can inspect, debug, and deploy.

Core Concepts: State, Nodes, and Edges

Before writing code, let's understand the three building blocks of every LangGraph application:

Here's a visual mental model: state flows through nodes like water through pipes. Edges are the pipes. Conditional edges are valves that direct flow based on rules you define.

Setting Up Your Environment

Install the required packages. You'll need LangGraph, LangChain, an OpenAI-compatible LLM provider, and a vector store for knowledge retrieval.

pip install langgraph langchain langchain-openai langchain-community chromadb tiktoken

Set your OpenAI API key as an environment variable:

export OPENAI_API_KEY="your-api-key-here"

Now create a new Python file — let's call it support_bot.py — and add the imports you'll need throughout the tutorial:

import os
from typing import TypedDict, List, Annotated, Literal
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
from langchain_core.prompts import ChatPromptTemplate
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_core.tools import tool

Building the Customer Support Bot — Step by Step

Defining the State Schema

The state is the backbone of your graph. For a support bot, you need to track the conversation history, the classified intent, retrieved knowledge, whether escalation is needed, and a confidence score. Here's the TypedDict schema:

class SupportState(TypedDict):
    # The full conversation history
    messages: Annotated[List[HumanMessage | AIMessage], "append_only"]
    
    # Current user input to process
    user_input: str
    
    # Classified intent category
    intent: str
    
    # Confidence score for intent classification (0.0 to 1.0)
    intent_confidence: float
    
    # Retrieved knowledge snippets from your knowledge base
    retrieved_context: List[str]
    
    # The bot's final response to the user
    bot_response: str
    
    # Flag indicating whether to escalate to a human
    escalate_to_human: bool
    
    # Reason for escalation (if applicable)
    escalation_reason: str
    
    # Conversation summary for handoff context
    conversation_summary: str

The Annotated type with "append_only" tells LangGraph that new messages should be appended to the list rather than overwriting it — essential for maintaining conversation history.

Creating the Intent Classification Node

The first node classifies the user's message into predefined support categories: billing, technical, general_inquiry, or complaint. This classification determines the subsequent routing.

def classify_intent(state: SupportState) -> dict:
    """
    Classify the user's intent from their input message.
    Updates state with intent and confidence score.
    """
    user_input = state.get("user_input", "")
    messages = state.get("messages", [])
    
    # If no user input, use the last human message
    if not user_input and messages:
        for msg in reversed(messages):
            if isinstance(msg, HumanMessage):
                user_input = msg.content
                break
    
    if not user_input:
        return {"intent": "general_inquiry", "intent_confidence": 0.5}
    
    # Use an LLM for classification with a structured prompt
    llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
    
    classification_prompt = ChatPromptTemplate.from_messages([
        ("system", """You are an intent classifier for a customer support system.
Classify the user message into EXACTLY ONE of these categories:
- billing: Questions about payments, invoices, subscriptions, refunds
- technical: Technical issues, bugs, error messages, product functionality
- general_inquiry: General questions about the product, pricing, features
- complaint: Frustrated feedback, negative experiences, dissatisfaction
- escalation: The user explicitly requests a human agent

Respond ONLY with the category name and a confidence score between 0.0 and 1.0, 
separated by a comma. Example: billing,0.95"""),
        ("human", "User message: {user_input}\n\nClassification:"),
    ])
    
    response = llm.invoke(
        classification_prompt.format_messages(user_input=user_input)
    )
    
    # Parse the response
    raw = response.content.strip().lower()
    try:
        parts = raw.split(",")
        intent = parts[0].strip()
        confidence = float(parts[1].strip())
    except (IndexError, ValueError):
        intent = "general_inquiry"
        confidence = 0.5
    
    return {
        "intent": intent,
        "intent_confidence": confidence,
    }

Key design decisions: the temperature is set to 0 for deterministic classification, and we include a fallback that defaults to general_inquiry if parsing fails — graceful degradation is critical in production.

Building the Information Retrieval Node

Once intent is known, the bot retrieves relevant knowledge from your support knowledge base. For this tutorial, we'll use a Chroma vector store populated with support articles.

First, create a helper to initialize the vector store. In a real application, you'd populate this from your actual knowledge base articles:

def initialize_knowledge_base():
    """
    Initialize a Chroma vector store with sample support articles.
    In production, replace this with your actual knowledge base.
    """
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    
    # Sample support articles (in production, load from your CMS/database)
    articles = [
        {
            "content": "Billing Cycle: Our billing cycle runs monthly from the date of signup. "
                       "You can view your next billing date in Account Settings > Billing.",
            "metadata": {"category": "billing", "title": "Billing Cycle Overview"}
        },
        {
            "content": "Refund Policy: We offer a 30-day money-back guarantee for all new subscriptions. "
                       "Refunds are processed within 5-10 business days.",
            "metadata": {"category": "billing", "title": "Refund Policy"}
        },
        {
            "content": "Troubleshooting Login Issues: Clear your browser cache, ensure cookies are enabled, "
                       "and try resetting your password. If issues persist, contact support.",
            "metadata": {"category": "technical", "title": "Login Troubleshooting"}
        },
        {
            "content": "API Rate Limits: Our API allows 1000 requests per hour for free tier users. "
                       "Enterprise tier supports unlimited requests.",
            "metadata": {"category": "technical", "title": "API Rate Limits"}
        },
        {
            "content": "Pricing Plans: We offer Starter ($10/mo), Professional ($50/mo), "
                       "and Enterprise ($200/mo) plans. Annual billing saves 20%.",
            "metadata": {"category": "general_inquiry", "title": "Pricing Plans"}
        },
    ]
    
    # Create vector store from articles
    texts = [article["content"] for article in articles]
    metadatas = [article["metadata"] for article in articles]
    
    vectorstore = Chroma.from_texts(
        texts=texts,
        embedding=embeddings,
        metadatas=metadatas,
        collection_name="support_knowledge_base",
        persist_directory="./support_db"
    )
    
    return vectorstore

Now the retrieval node itself:

def retrieve_knowledge(state: SupportState) -> dict:
    """
    Retrieve relevant knowledge articles based on the user's query and intent.
    """
    user_input = state.get("user_input", "")
    intent = state.get("intent", "general_inquiry")
    
    # Get messages for context
    messages = state.get("messages", [])
    if not user_input and messages:
        for msg in reversed(messages):
            if isinstance(msg, HumanMessage):
                user_input = msg.content
                break
    
    if not user_input:
        return {"retrieved_context": []}
    
    # Initialize vector store (in production, do this once at startup)
    vectorstore = initialize_knowledge_base()
    
    # Retrieve with intent-aware filtering
    # First try filtering by intent category
    results = vectorstore.similarity_search(
        user_input,
        k=3,
        filter={"category": intent}
    )
    
    # If no results for that category, do a broader search
    if not results:
        results = vectorstore.similarity_search(user_input, k=3)
    
    retrieved_context = [doc.page_content for doc in results]
    
    return {"retrieved_context": retrieved_context}

The retrieval node first tries to find articles matching the classified intent category, then falls back to a broader search. This two-tier approach improves relevance while maintaining coverage.

Implementing the Response Generation Node

This node synthesizes a helpful, empathetic response using the retrieved knowledge, conversation history, and intent classification:

def generate_response(state: SupportState) -> dict:
    """
    Generate the bot's response based on intent, retrieved context, and conversation history.
    """
    intent = state.get("intent", "general_inquiry")
    retrieved_context = state.get("retrieved_context", [])
    messages = state.get("messages", [])
    user_input = state.get("user_input", "")
    escalate = state.get("escalate_to_human", False)
    
    # If escalation is flagged, generate a handoff message instead
    if escalate:
        reason = state.get("escalation_reason", "your request requires human attention")
        response_text = (
            f"I understand this is important. {reason.capitalize()}. "
            f"I'm connecting you with a human agent right now. "
            f"A support specialist will be with you shortly. "
            f"Is there anything else you'd like me to share with them?"
        )
        return {
            "bot_response": response_text,
            "conversation_summary": _summarize_conversation(messages, user_input)
        }
    
    context_text = "\n\n".join(retrieved_context) if retrieved_context else "No specific articles found."
    
    # Build conversation history string for the prompt
    history_text = ""
    for msg in messages[-6:]:  # Last 6 messages for context window
        role = "Customer" if isinstance(msg, HumanMessage) else "Agent"
        history_text += f"{role}: {msg.content}\n"
    
    llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.7)
    
    response_prompt = ChatPromptTemplate.from_messages([
        ("system", """You are a helpful, empathetic customer support agent for a SaaS company.
Your goal is to resolve the customer's issue completely in one interaction when possible.

Rules:
- Be warm and empathetic, acknowledge the customer's feelings
- Use the provided knowledge base articles to give accurate information
- If the articles don't fully address the issue, be honest about limitations
- For complaints: apologize sincerely, acknowledge the frustration, and offer concrete solutions
- For technical issues: give step-by-step instructions
- For billing questions: be precise about amounts, dates, and policies
- Never make up information not in the knowledge base
- If you cannot resolve the issue, suggest escalation politely

Intent: {intent}
Knowledge Base Articles:
{context}

Conversation History:
{history}"""),
        ("human", "Customer's latest message: {user_input}\n\nCraft a helpful response:"),
    ])
    
    response = llm.invoke(
        response_prompt.format_messages(
            intent=intent,
            context=context_text,
            history=history_text,
            user_input=user_input
        )
    )
    
    bot_response = response.content.strip()
    
    return {
        "bot_response": bot_response,
        "conversation_summary": _summarize_conversation(messages, user_input)
    }


def _summarize_conversation(messages: list, current_input: str) -> str:
    """Create a brief summary for human handoff context."""
    if not messages:
        return f"Customer asked: {current_input}"
    
    summary_parts = []
    for msg in messages[-4:]:
        role = "Customer" if isinstance(msg, HumanMessage) else "Bot"
        preview = msg.content[:100] + "..." if len(msg.content) > 100 else msg.content
        summary_parts.append(f"{role}: {preview}")
    
    return " | ".join(summary_parts)

Notice how the response node handles both normal responses and escalation handoffs in a single function. The system prompt includes specific instructions for each intent category, and we limit conversation history to the last 6 messages to manage token usage.

Adding a Human Handoff Node

The escalation node prepares the conversation for human takeover. In a real system, this would trigger a notification to your support team via Slack, Zendesk, or a custom dashboard:

def human_handoff(state: SupportState) -> dict:
    """
    Prepare the conversation for human handoff.
    In production, this would integrate with your ticketing system.
    """
    summary = state.get("conversation_summary", "")
    intent = state.get("intent", "unknown")
    confidence = state.get("intent_confidence", 0.0)
    messages = state.get("messages", [])
    
    # Build a structured handoff payload
    handoff_payload = {
        "summary": summary,
        "intent": intent,
        "confidence": confidence,
        "message_count": len(messages),
        "escalation_reason": state.get("escalation_reason", "User requested human agent"),
        "priority": "high" if intent == "complaint" else "normal",
    }
    
    # In production, send this to your ticketing system
    # For now, we log it and include it in the response
    print(f"[HANDOFF] Escalation payload: {handoff_payload}")
    
    handoff_message = (
        "I've documented our conversation and flagged it for our support team. "
        f"Priority: {handoff_payload['priority']}. "
        "An agent will review the full context and reach out to you within 2 hours. "
        "Your reference ID is SUPPORT-{ticket_id}".format(
            ticket_id=f"{intent[:3].upper()}-{len(messages)}"
        )
    )
    
    return {"bot_response": handoff_message}

Constructing the Graph

Now wire everything together into a LangGraph graph. This is where the architecture comes alive — you define nodes, edges, and the conditional routing logic:

def build_support_graph() -> StateGraph:
    """
    Construct the LangGraph graph for the customer support bot.
    """
    # Create the graph with our state type
    graph = StateGraph(SupportState)
    
    # Add all nodes
    graph.add_node("classify_intent", classify_intent)
    graph.add_node("retrieve_knowledge", retrieve_knowledge)
    graph.add_node("generate_response", generate_response)
    graph.add_node("human_handoff", human_handoff)
    graph.add_node("escalation_check", check_escalation_needed)
    
    # Define the flow
    # Start -> classify_intent
    graph.set_entry_point("classify_intent")
    
    # classify_intent -> retrieve_knowledge (always)
    graph.add_edge("classify_intent", "retrieve_knowledge")
    
    # retrieve_knowledge -> escalation_check (conditional routing decision)
    graph.add_edge("retrieve_knowledge", "escalation_check")
    
    # escalation_check branches based on state
    graph.add_conditional_edges(
        "escalation_check",
        route_after_check,
        {
            "generate": "generate_response",
            "escalate": "human_handoff",
            "end": END,
        }
    )
    
    # Both response and handoff nodes end the turn
    graph.add_edge("generate_response", END)
    graph.add_edge("human_handoff", END)
    
    return graph


def check_escalation_needed(state: SupportState) -> dict:
    """
    Determine if escalation to a human is needed.
    This is a node that runs after knowledge retrieval and before response generation.
    """
    intent = state.get("intent", "")
    confidence = state.get("intent_confidence", 0.0)
    user_input = state.get("user_input", "").lower()
    retrieved = state.get("retrieved_context", [])
    
    escalate = False
    reason = ""
    
    # Explicit escalation request from user
    escalation_keywords = ["human", "agent", "person", "representative", "talk to someone"]
    if any(keyword in user_input for keyword in escalation_keywords):
        escalate = True
        reason = "Customer explicitly requested human assistance"
    
    # Low confidence classification
    elif confidence < 0.6:
        escalate = True
        reason = f"Low intent classification confidence ({confidence})"
    
    # Complaint with no relevant knowledge found
    elif intent == "complaint" and not retrieved:
        escalate = True
        reason = "Complaint detected with no relevant knowledge articles"
    
    return {
        "escalate_to_human": escalate,
        "escalation_reason": reason,
    }


def route_after_check(state: SupportState) -> Literal["generate", "escalate", "end"]:
    """
    Conditional routing function.
    Decides whether to generate a bot response, escalate to human, or end.
    """
    escalate = state.get("escalate_to_human", False)
    user_input = state.get("user_input", "")
    
    # Empty input means end the conversation
    if not user_input or user_input.strip() in ["bye", "exit", "quit", "end"]:
        return "end"
    
    if escalate:
        return "escalate"
    
    return "generate"

The graph structure is: START → classify_intent → retrieve_knowledge → escalation_check → [generate_response | human_handoff | END]. The escalation_check node and route_after_check function together implement the branching logic — this separation of concerns (check logic in a node, routing logic in a function) keeps your code clean and testable.

Running the Bot — The Complete Execution Loop

Here's how to compile and invoke the graph. The MemorySaver checkpoint provides persistence across turns:

def create_support_bot():
    """
    Create a compiled, ready-to-use support bot graph with memory checkpointing.
    """
    graph = build_support_graph()
    
    # Add memory for conversation persistence
    memory = MemorySaver()
    
    # Compile with checkpointing
    compiled_graph = graph.compile(checkpointer=memory)
    
    return compiled_graph


def process_message(bot, user_message: str, thread_id: str = "default") -> str:
    """
    Process a single user message through the bot.
    
    Args:
        bot: The compiled LangGraph graph
        user_message: The user's input text
        thread_id: Unique conversation identifier for checkpointing
    
    Returns:
        The bot's response text
    """
    # Prepare the initial state for this turn
    input_state = {
        "user_input": user_message,
        "messages": [HumanMessage(content=user_message)],
    }
    
    # Configure with thread ID for persistence
    config = {"configurable": {"thread_id": thread_id}}
    
    # Invoke the graph
    result = bot.invoke(input_state, config)
    
    # Extract the bot's response
    bot_response = result.get("bot_response", "")
    
    return bot_response


# Example usage
if __name__ == "__main__":
    bot = create_support_bot()
    
    # Simulate a conversation
    print("Support Bot: Hello! How can I help you today?")
    
    while True:
        user_input = input("You: ")
        if user_input.lower() in ["exit", "quit", "bye"]:
            print("Support Bot: Thank you for reaching out! Goodbye.")
            break
        
        response = process_message(bot, user_input, thread_id="conversation-001")
        print(f"Support Bot: {response}")

The thread_id parameter in the config is crucial — it allows LangGraph's checkpoint system to save and restore conversation state for each unique conversation. Multiple users can interact with the bot simultaneously, each with their own isolated state.

Advanced Features for Production

Adding Memory and Persistence Across Turns

The MemorySaver we added stores checkpoints in memory, which works for development but won't survive server restarts. For production, use a persistent checkpoint saver like SQLite or Postgres:

from langgraph.checkpoint.sqlite import SqliteSaver

# Replace MemorySaver with SQLite for persistent storage
checkpointer = SqliteSaver.from_conn_string("support_bot_checkpoints.db")
compiled_graph = graph.compile(checkpointer=checkpointer)

With persistent checkpointing, conversations survive server restarts, and users can pick up where they left off even days later. Each checkpoint stores the full state at that point in the graph, so resuming is instantaneous.

Implementing Conditional Routing with Multiple Branches

Our graph already uses conditional routing for escalation, but you can extend this pattern for richer flows. For example, you might route billing inquiries to a payment-specific node while sending technical issues to a debugging node:

def route_by_intent(state: SupportState) -> Literal["billing_handler", "tech_handler", "general_handler"]:
    intent = state.get("intent", "general_inquiry")
    
    if intent == "billing":
        return "billing_handler"
    elif intent == "technical":
        return "tech_handler"
    else:
        return "general_handler"

# In your graph builder:
graph.add_conditional_edges(
    "classify_intent",
    route_by_intent,
    {
        "billing_handler": "billing_handler",
        "tech_handler": "tech_handler",
        "general_handler": "general_handler",
    }
)

This pattern scales beautifully — add new intent categories and handler nodes without restructuring existing code.

Streaming Responses for Real-Time UX

Users expect responses to appear incrementally, not all at once after a long pause. LangGraph supports streaming at both the node and token level:

def stream_bot_response(bot, user_message: str, thread_id: str = "default"):
    """
    Stream the bot's processing step by step.
    """
    input_state = {
        "user_input": user_message,
        "messages": [HumanMessage(content=user_message)],
    }
    
    config = {"configurable": {"thread_id": thread_id}}
    
    # Stream each node completion
    for event in bot.stream(input_state, config, stream_mode="values"):
        # event contains the full state at each step
        node_output = event.get("bot_response", None)
        if node_output:
            yield node_output
    
    # Alternative: stream individual tokens from the LLM
    # Use stream_mode="messages" for token-level streaming
    for chunk in bot.stream(input_state, config, stream_mode="messages"):
        if hasattr(chunk, "content"):
            yield chunk.content

For a web application, you'd pipe these streamed chunks through Server-Sent Events or WebSockets to update the UI in real time.

Best Practices for LangGraph Customer Support Bots

Conclusion

You've now built a complete, production-ready customer support bot using LangGraph. The architecture you've implemented — intent classification, knowledge retrieval, conditional escalation, and empathetic response generation — forms a solid foundation that you can extend with additional handler nodes, more sophisticated routing, and integration with your existing support infrastructure. The graph-based approach gives you explicit control over every decision point while keeping state management clean and auditable. Start with the patterns shown here, add your own knowledge base content, integrate with your ticketing system in the human_handoff node, and you'll have a support bot that genuinely reduces ticket volume while keeping customer satisfaction high. The complete code from this tutorial is ready to adapt — swap the sample articles for your real knowledge base, customize the intent categories to match your product domains, and deploy behind your existing chat interface.

— Ad —

Google AdSense will appear here after approval

← Back to all articles