LangGraphLangGraph 1.2 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
38 small wins to finish your pathNext lesson →

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

PieceFrom lesson
Agent loop (call model, run tools, loop back)Agent loop
A real chat model with the tools boundChat models
A tool the model can callTools and bind_tools
Memory across turnsCheckpointer
A human approval before a refundinterrupt

The imports

Start with the imports. Each line brings in one thing from an earlier lesson.

python
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

The 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.

python
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.

python
@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.

python
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_tools gives 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.

python
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 memory
  • tools_condition sends a tool call to the tools node, and anything else to the end.
  • The edge from tools back to call_model is the loop: run a tool, then let the model use the result.
  • InMemorySaver saves the state per thread, so the agent remembers past turns on the same thread_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.

python
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 A17

The 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.

ExampleAPI key
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)

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 the interrupt and paused (__interrupt__ was in the result). Resuming with Command(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.
Watch out. Memory needs the same 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.
Try it yourself
  • 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.