Project: support agent
The support agent brings the course together: one graph that reads a ticket, looks things up with a tool, remembers the conversation across turns, handles an order it cannot find, and pauses for a human before it refunds anything.
Last updated: 29 Sep, 2026 · LangGraph 1.2
Every piece here appeared in an earlier lesson. The agent loop calls a model and runs tools; a checkpointer gives it memory per thread; an interrupt puts a human in front of a refund. Assembled, and run on more than the happy path, they are a support agent.
The pieces, and where each came from
| Piece | From lesson |
|---|---|
| Agent loop (call model, run tools, loop back) | Agent loop |
| A real chat model with the tools bound | Chat models |
| A tool the model can call | Tools and bind_tools |
| Memory across turns | Checkpointer |
| A human approval before a refund | interrupt |
The imports
Start with the imports. Each line brings in one thing from an earlier lesson.
from langgraph.graph import StateGraph, START, MessagesState
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command
from langchain.chat_models import init_chat_model
from langchain.tools import tool
from langchain.messages import HumanMessage, SystemMessageThe lookup tool
A tool the model can call. It knows one order and returns not found for any id it does not have, so a bad id is answered, not crashed.
ORDERS = {"A17": "shipped on 3 March"} # the only order the shop knows
@tool
def lookup_order(order_id: str) -> str:
"""Look up an order by id."""
if order_id not in ORDERS:
return f"Order {order_id}: not found." # the failure path
return f"Order {order_id}: {ORDERS[order_id]}."The refund tool
The refund is the action it cannot undo, so it calls interrupt first: the run pauses there and waits for a human answer before it refunds or declines.
@tool
def refund_order(order_id: str) -> str:
"""Refund an order (needs human approval)."""
if not interrupt(f"Approve refund for {order_id}?"): # pause for a human
return f"Refund for {order_id} was declined."
return f"Order {order_id}: refunded."The model node
The model gets both tools with bind_tools and a system prompt that keeps it grounded. The node sends the system prompt plus the whole thread, and appends the model's reply: a tool call or an answer.
model = init_chat_model("groq:openai/gpt-oss-120b", temperature=0) # uses your GROQ_API_KEY
model_with_tools = model.bind_tools([lookup_order, refund_order])
SYSTEM = SystemMessage(
"You are the support assistant for a small online shop. Use the tools to answer. "
"Answer in one short sentence that states only what the tools returned; do not add anything else. "
"If you need an order id and the conversation does not give one, ask for it.")
def call_model(state: MessagesState):
return {"messages": [model_with_tools.invoke([SYSTEM] + state["messages"])]}bind_toolsgives the model both tools, and the model decides which one a message needs.- The system prompt keeps answers to what the tools returned, and tells the model to ask for an order id when the conversation has none.
- The model reads the whole thread on every call, so an id given on an earlier turn is still in front of it. The checkpointer is what keeps that thread between calls.
Wiring the graph
Wire the pieces into a graph. The edges are what make it loop; both tools live in one ToolNode.
b = StateGraph(MessagesState)
b.add_node("call_model", call_model)
b.add_node("tools", ToolNode([lookup_order, refund_order])) # both tools live here
b.add_edge(START, "call_model")
b.add_conditional_edges("call_model", tools_condition) # tool call? -> tools, else -> stop
b.add_edge("tools", "call_model") # after a tool, back to the model
agent = b.compile(checkpointer=InMemorySaver()) # the checkpointer is the memorytools_conditionsends a tool call to thetoolsnode, and anything else to the end.- The edge from
toolsback tocall_modelis the loop: run a tool, then let the model use the result. InMemorySaversaves the state per thread, so the agent remembers past turns on the samethread_id.
Running it: memory, a failure, and a pause
Two turns on the same thread so the second remembers the first, then the same question on a fresh thread to prove the memory is real. The end-to-end block below adds the not-found path and the refund pause.
def say(text, cfg):
return agent.invoke({"messages": [HumanMessage(text)]}, cfg)["messages"][-1].content
t1 = {"configurable": {"thread_id": "1"}}
say("Hi, my order id is A17.", t1) # turn 1: gives the id
print("same thread :", say("Where is it?", t1)) # turn 2: the model reads A17 from turn 1
t2 = {"configurable": {"thread_id": "2"}}
print("new thread :", say("Where is it?", t2)) # a fresh thread has no memory of A17The support agent end to end
The same pieces in one file, run on four situations: memory on a thread, a fresh thread with no memory, an unknown order, and a refund that pauses for approval.
from langgraph.graph import StateGraph, START, MessagesState
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command
from langchain.chat_models import init_chat_model
from langchain.tools import tool
from langchain.messages import HumanMessage, SystemMessage
ORDERS = {"A17": "shipped on 3 March"} # the only order the shop knows
@tool
def lookup_order(order_id: str) -> str:
"""Look up an order by id."""
if order_id not in ORDERS:
return f"Order {order_id}: not found." # the failure path
return f"Order {order_id}: {ORDERS[order_id]}."
@tool
def refund_order(order_id: str) -> str:
"""Refund an order (needs human approval)."""
if not interrupt(f"Approve refund for {order_id}?"): # pause for a human
return f"Refund for {order_id} was declined."
return f"Order {order_id}: refunded."
model = init_chat_model("groq:openai/gpt-oss-120b", temperature=0) # uses your GROQ_API_KEY
model_with_tools = model.bind_tools([lookup_order, refund_order])
SYSTEM = SystemMessage(
"You are the support assistant for a small online shop. Use the tools to answer. "
"Answer in one short sentence that states only what the tools returned; do not add anything else. "
"If you need an order id and the conversation does not give one, ask for it.")
def call_model(state: MessagesState):
return {"messages": [model_with_tools.invoke([SYSTEM] + state["messages"])]}
b = StateGraph(MessagesState)
b.add_node("call_model", call_model)
b.add_node("tools", ToolNode([lookup_order, refund_order]))
b.add_edge(START, "call_model")
b.add_conditional_edges("call_model", tools_condition)
b.add_edge("tools", "call_model")
agent = b.compile(checkpointer=InMemorySaver())
def say(text, cfg):
return agent.invoke({"messages": [HumanMessage(text)]}, cfg)["messages"][-1].content
# memory: two turns on the same thread
t1 = {"configurable": {"thread_id": "1"}}
say("Hi, my order id is A17.", t1) # turn 1: gives the id
print("same thread :", say("Where is it?", t1)) # turn 2: the model reads A17 from turn 1
# the same question on a fresh thread has no memory of A17
t2 = {"configurable": {"thread_id": "2"}}
print("new thread :", say("Where is it?", t2))
# failure path: an order that is not in the system
print("unknown :", say("Where is order B99?", t2))
# human in the loop: a refund pauses for approval, then resumes
t3 = {"configurable": {"thread_id": "3"}}
paused = agent.invoke({"messages": [HumanMessage("Refund order A17.")]}, t3)
print("paused? :", "__interrupt__" in paused)
resumed = agent.invoke(Command(resume=True), t3)
print("after yes :", resumed["messages"][-1].content)same thread : It was shipped on 3 March. new thread : Could you please provide your order ID? unknown : Order B99 not found. paused? : True after yes : Order A17: refunded.
What the runs showed
- Memory is real. On thread "1" the id came in on turn one. On turn two the model still had that message in front of it, looked up A17 and answered that it shipped on 3 March. The same question on the fresh thread "2" had no id anywhere, so the model asked for one. The difference is the checkpointer.
- A failure has an answer. For
B99, an id the shop does not know, the tool returned not found and the model passed that on, rather than a crash or a made-up date. - A refund stops for a human. "Refund order A17." made the model call
refund_order, which ran up to theinterruptand paused (__interrupt__was in the result). Resuming withCommand(resume=True)approved it, and the model reported the refund from the tool's result.
When to build an agent like this
- A support or operations assistant that looks things up and remembers a customer.
- Any assistant that must pause for a person before an action it cannot take back.
thread_id across turns. Thread "2" above never saw the id, so it asked for one. Passing a new thread_id each turn is the usual reason an agent forgets what was said a moment ago.Related
- Previous: Multi-agent supervisor
- Next: Retrieval
- Add a third turn on thread "1" and confirm the earlier ones are still there.
- Resume the refund with
Command(resume=False)and confirm the tool returns the declined message.
You understood something today that you didn't yesterday.