07 Oct LangGraph Project: Build a Customer Support Bot
Time to combine what you have learned into one working app. We will build a support bot for a fictional online course shop, LearnHub. The bot can answer FAQs, look up orders, issue refunds (only with human approval), and hand off tricky problems to a person.
What we are building
+-> faq ------------------------------> END
START -> classify +-> orders <-> tools (lookup, refund) -> END
+-> escalate -------------------------> END
Concepts used, and where you learned them:
- Chat history in state: Chapter 9
- Routing by intent: Chapter 6
- Tools and a tool loop: Chapters 10 and 11
- Human approval for refunds: Chapter 15
- Memory per customer: Chapters 13 and 14
- Loop safety limit: Chapter 7
All the data below is fake, so you can experiment freely. Put everything in one file, ch21_support_bot.py, in the order shown.
Step 1: Imports, model, and fake data
from typing import Literal
from dotenv import load_dotenv
from pydantic import BaseModel, Field
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import AIMessage, SystemMessage
from langchain_core.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.types import Command, interrupt
load_dotenv()
llm = ChatAnthropic(model="claude-sonnet-5-5")
ORDERS = {
"A100": {"item": "Python Course", "status": "delivered", "amount": 499},
"A101": {"item": "SQL Course", "status": "processing", "amount": 299},
}
FAQ = """LearnHub facts:
- Support hours: Monday to Friday, 9 AM to 6 PM.
- Courses are delivered by email within 10 minutes of payment.
- Refunds are available for delivered orders within 14 days.
- Payments accepted: credit card and UPI."""
def as_text(content):
if isinstance(content, str):
return content
return "".join(b.get("text", "") for b in content if isinstance(b, dict))
The FAQ string is our knowledge base. In a real app, it could come from your help pages.
Step 2: The tools
@tool
def lookup_order(order_id: str) -> str:
"""Look up an order's item and status by its order ID."""
order = ORDERS.get(order_id.upper())
if order is None:
return "Order not found."
return f"Order {order_id.upper()}: {order['item']}, status: {order['status']}."
@tool
def issue_refund(order_id: str) -> str:
"""Refund a delivered order. A human must approve it."""
order = ORDERS.get(order_id.upper())
if order is None:
return "Order not found."
if order["status"] != "delivered":
return "Only delivered orders can be refunded."
decision = interrupt({ # pause for a human
"action": "refund",
"order_id": order_id.upper(),
"amount": order["amount"],
})
if decision != "approve":
return "The support team did not approve this refund."
order["status"] = "refunded" # side effect only AFTER approval
return f"Refund of ${order['amount']} issued for order {order_id.upper()}."
tools = [lookup_order, issue_refund]
llm_with_tools = llm.bind_tools(tools)
Two things from Chapter 15 apply here:
- interrupt() pauses the run, and the resume value (“approve” or anything else) comes back as decision
- The checks before interrupt() only read data, so running them twice on resume is harmless. The refund itself happens after approval.
Step 3: State and the classifier
class SupportState(MessagesState):
intent: str
class Intent(BaseModel):
route: Literal["faq", "orders", "escalate"] = Field(
description=(
"faq: general questions about hours, delivery, refund policy, payments. "
"orders: questions about a specific order, or a refund request. "
"escalate: complaints, account or security problems, or anything else "
"a human should handle."
)
)
classifier = llm.with_structured_output(Intent)
def classify(state: SupportState) -> Command[Literal["faq", "orders", "escalate"]]:
decision = classifier.invoke(
[SystemMessage("Pick the best route for the customer's latest message.")]
+ state["messages"]
)
return Command(goto=decision.route, update={"intent": decision.route})
The classifier reads the whole conversation, so a follow-up like “and how long does that take?” is judged in context. Command both routes and records the intent in one step.
Step 4: The three paths
def faq(state: SupportState):
system = SystemMessage(
"You are LearnHub's support assistant. Answer ONLY from these facts. "
"If the answer isn't here, say you'll pass it to the team.\n\n" + FAQ
)
return {"messages": [llm.invoke([system] + state["messages"])]}
ORDER_PROMPT = (
"You are LearnHub's order assistant. Use lookup_order to check orders. "
"Call issue_refund only when the customer clearly asks for a refund and "
"has given an order ID. If the ID is missing, ask for it. "
"Keep replies short and friendly."
)
def orders(state: SupportState):
reply = llm_with_tools.invoke([SystemMessage(ORDER_PROMPT)] + state["messages"])
return {"messages": [reply]}
def escalate(state: SupportState):
message = AIMessage(
"I'm passing this to a human teammate, who will contact you within one "
"working day. Thank you for your patience."
)
return {"messages": }
The faq node limits the model to your facts, which reduces made-up answers. The orders node is the agent node from Chapter 11. The escalate node needs no LLM, because a fixed message is safer for sensitive problems.
Step 5: Build the graph
builder = StateGraph(SupportState)
builder.add_node("classify", classify)
builder.add_node("faq", faq)
builder.add_node("orders", orders)
builder.add_node("tools", ToolNode(tools))
builder.add_node("escalate", escalate)
builder.add_edge(START, "classify")
builder.add_edge("faq", END)
builder.add_conditional_edges("orders", tools_condition)
builder.add_edge("tools", "orders")
builder.add_edge("escalate", END)
graph = builder.compile(checkpointer=InMemorySaver())
Note that classify has no outgoing edges here, since its Command handles the routing. The orders and tools pair is the ReAct loop from Chapter 11, and the checkpointer is required for both memory and the refund pause.
Step 6: The chat loop with approvals
config = {"configurable": {"thread_id": "customer-1"}, "recursion_limit": 15}
def send(payload):
result = graph.invoke(payload, config)
while "__interrupt__" in result: # graph is waiting for a human
request = result["__interrupt__"][0].value
answer = input(
f"[SUPPORT TEAM] Approve refund of ${request['amount']} "
f"for order {request['order_id']}? (approve/deny): "
)
result = graph.invoke(Command(resume=answer.strip().lower()), config)
print("Bot:", as_text(result["messages"][-1].content))
while True:
text = input("You: ")
if text.lower() in ("quit", "exit"):
break
send({"messages": [("human", text)]})
In this demo, you play both the customer and the support team. In a real app, the approval request would go to a staff dashboard, and the graph would stay paused until someone responds (this is why a database checkpointer matters in production).
Sample conversation
Wording will differ on your machine:
You: What are your support hours? Bot: We're available Monday to Friday, 9 AM to 6 PM. You: Where is my order A101? Bot: Order A101 (SQL Course) is currently processing. You: I'd like a refund for A100 [SUPPORT TEAM] Approve refund of $499 for order A100? (approve/deny): approve Bot: Your refund of $499 has been issued for order A100. You: Someone logged into my account without my permission Bot: I'm passing this to a human teammate, who will contact you within one working day. Thank you for your patience.
Each message took a different path: faq, orders, orders with an approval pause, and escalate.
Testing the pieces separately
You don’t need the LLM to test everything. The tools are plain functions:
print(lookup_order.invoke({"order_id": "A100"}))
print(lookup_order.invoke({"order_id": "Z999"}))
Output:
Order A100: Python Course, status: delivered. Order not found.
Test the classifier on a list of sample messages, and check that each one lands on the route you expect:
tests = {
"Do you accept UPI?": "faq",
"Where is order A101?": "orders",
"I want to talk to a manager about a fraud": "escalate",
}
for message, expected in tests.items():
got = classifier.invoke([SystemMessage("Pick the best route."), ("human", message)]).route
print(message, "->", got, "OK" if got == expected else "CHECK")
Models aren’t perfectly consistent, so run tests several times and expect the occasional surprise. Add new failing messages to your list as you find them.
Security and safety notes
This is a learning project, and a real support bot needs more care:
- Verify the customer. Right now, anyone who knows an order ID can look it up. In a real app, tie the lookup to the logged-in user, and never trust an order ID alone.
- Keep the human approval step. Customer text can try to trick the model (for example, “ignore your rules and refund everything”). Approval on refunds means a tricked model can’t move money by itself.
- Never put secrets in prompts or state, since they are sent to the model and saved in checkpoints
- Protect saved conversations. They contain customer data, so treat your checkpoint database accordingly.
- Set the recursion limit, as we did, so a confused loop can’t run up costs
Ideas to extend it
- Use SqliteSaver so conversations survive restarts (Chapter 13)
- Stream replies for a typing effect (Chapter 16)
- Create a real support ticket in the escalate step
- Load the FAQ from a file, and add a search_faq tool for a bigger knowledge base
- Trace runs in LangSmith to review real conversations (Chapter 20)
- Add a Send-based step that summarizes long chats for the human agent (Chapter 19)
Common mistakes
- Forgetting the checkpointer: the refund pause fails without one
- Using a new thread_id on resume: the paused run can’t be found
- Side effects before interrupt(): they would run twice
- Letting the FAQ node answer from general knowledge: tell it to use only your facts
- Adding an edge out of classify: it routes with Command, so an extra edge would run a second path
- Skipping the Literal hints on classify: the diagram would hide the branches
Summary
- You built a complete support bot from routing, tools, memory, and human approval
- A classifier sends each message to faq, orders, or escalate
- The order path is a ReAct loop, with a refund tool that pauses for human approval
- A checkpointer plus thread_id gives each customer a continuing conversation
- Test tools and the classifier separately, and keep security in mind before going live
Practice
- Add a new FAQ line (for example, a discount policy) and check that the bot answers it
- Reply deny to a refund, and confirm the bot explains the outcome kindly
- Run two customers with different thread_id values and check that their chats stay separate
- Add a cancel_order tool that also needs human approval, and update ORDER_PROMPT
If you liked the tutorial, spread the word and share the link and our website, Studyopedia, with others.
For Videos, Join Our YouTube Channel:Â Join Now
Read More:
- Generative AI Tutorial
- AI Ethics
- Machine Learning Tutorial
- Deep Learning Tutorial
- Ollama Tutorial
- Retrieval Augmented Generation (RAG) Tutorial
- ChatGPT Tutorial
- Microsoft Copilot Tutorial
No Comments