Capstone: The Bookshop Support Agent

Everything in the series, in one working system: a supervisor that triages customers to specialists, an orders agent that runs real SQL against the warehouse, a recommendations agent backed by retrieval, and a refund path gated behind a human — persistent, streamable, and run end to end.

This is the chapter the whole series has been building toward. We assemble a complete customer-support system for the bookshop: a supervisor that routes each customer to the right specialist, an orders agent that answers by running SQL against the actual warehouse, a recommendations agent backed by retrieval, and a refund path that never moves money without a human’s yes. It’s persistent so conversations survive, it streams so a UI stays live, and every path was run end to end. Nothing here is new — it’s the primitives from all fourteen prior chapters, composed with judgment. Run against langgraph 1.2.9 with a local model and the dbt-bookshop DuckDB warehouse.

The single most important idea in the whole build is a design one, so it’s worth stating up front: different paths through the system deserve different amounts of trust in the model. Routing a book recommendation wrong is cheap; issuing a refund the customer didn’t earn is not. So the recommendations path leans fully on the model’s fluency, and the refund path puts a hard human gate in the graph the model cannot skip. Watch for that gradient as we go — it’s the thing that separates a demo from a system you’d actually deploy.

The shape

The system is a hand-built graph, because (as Chapter 14 predicted) it’s supervisor-shaped but has two things a plain prebuilt supervisor doesn’t: a refund path with an approval gate, and an orders path that runs SQL. The graph:

graph TD;
    __start__ --> supervisor;
    supervisor -.-> orders;
    supervisor -.-> refund;
    supervisor -.-> recommendations;
    orders --> __end__;
    refund --> __end__;
    recommendations --> __end__;

The supervisor hands off to one of three specialists, each a node. Let’s build them, then wire them, then follow a request through.

The state

One shared state carries the conversation and the fields the refund path needs:

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

class State(TypedDict):
    messages: Annotated[list, add_messages]
    intent: str
    refund_order: str
    refund_amount: float

messages uses add_messages so the conversation accumulates; the rest are plain channels the supervisor and refund node write. Every specialist reads the same messages, so each sees the full conversation that led to it.

The supervisor

The supervisor reads the latest message and hands off with a Command. Shown with rule-based routing, which is deterministic and exactly what you want for the hard-money refund path — you do not want a 3B model deciding whether something is a refund:

from typing import Literal
from langgraph.types import Command

def supervisor(state) -> Command[Literal["orders", "refund", "recommendations"]]:
    text = state["messages"][-1].content.lower()
    if "refund" in text:
        intent = "refund"
    elif "order" in text or "status" in text:
        intent = "orders"
    else:
        intent = "recommendations"
    return Command(update={"intent": intent}, goto=intent)

In production you’d often make this a model-driven supervisor — create_agent with handoff tools, or create_supervisor — for softer, more natural routing. The honest trade-off is the design gradient again: model routing reads intent better but is only as reliable as the model, so you mix it with hard rules for consequential paths. Route “what’s like Dune?” with the model; route “refund” with an if. A hybrid supervisor — model for the fuzzy cases, a rule that forces anything mentioning “refund” to the gated path regardless of what the model thought — is often the right production shape.

The orders agent runs real SQL

The orders specialist answers questions by querying the warehouse. Its tool opens the DuckDB read-only and runs SQL — the text-to-SQL path folded into the system:

import duckdb
from langchain_core.tools import tool

@tool
def run_sql(query: str) -> str:
    """Run a read-only SQL query against the bookshop warehouse."""
    con = duckdb.connect("bookshop.duckdb", read_only=True)
    try:
        return str(con.execute(query).fetchall()[:20])
    finally:
        con.close()

def orders(state):
    rows = run_sql("select status, count(*) from orders group by status")
    return {"messages": [AIMessage(f"Order status breakdown: {rows}")]}

Verified against the real warehouse, the orders path returned the genuine breakdown: [('placed', 2), ('shipped', 2), ('returned', 1), ('completed', 9), ('cancelled', 1)]. Note the tool opens the connection read-only — the Chapter 4 security principle in action: no query the model writes can modify the warehouse, because your code didn’t grant that power. In a full agent you’d give run_sql to a create_agent with the schema in its system prompt and let the model write the query from the customer’s question — that’s the model-quality-dependent part, flagged as such. What’s proven here is that the tool executes real SQL against a real database inside the graph: the “it hit the warehouse” is not a mock.

The refund path is gated

The refund specialist is where human-in-the-loop earns its place. It interrupts before touching money, surfacing the details for a human to approve:

from langgraph.types import interrupt

def refund(state):
    approved = interrupt({
        "action": "refund",
        "order": state["refund_order"],
        "amount": state["refund_amount"],
    })
    if approved != "yes":
        return {"messages": [AIMessage("Refund cancelled by an agent.")]}
    process_refund(state["refund_order"], state["refund_amount"])
    return {"messages": [AIMessage(f"Refunded ${state['refund_amount']} on {state['refund_order']}.")]}

Verified: when the supervisor routed a “I want a refund” message here, the graph paused — the invoke returned with __interrupt__ set and get_state(...).next equal to ('refund',) — and only after resuming with Command(resume="yes") did it complete with “Refunded $30.0 on A17.” The refund is a hard gate: the node suspends on the interrupt call, so no amount of model confusion can skip it. This is the safety property the whole architecture exists to provide, and it’s structural, not a prompt the model might ignore.

The recommendations agent

The third specialist answers “what should I read?” from the catalog, using the RAG pattern as a node — retrieve similar books, then let the model suggest from them:

def recommendations(state):
    question = state["messages"][-1].content
    hits = catalog_retriever.invoke(question)          # retrieve similar books
    context = "\n".join(d.page_content for d in hits)
    reply = model.invoke([
        SystemMessage(f"Recommend books to the customer, using only these:\n{context}"),
        HumanMessage(question),
    ])
    return {"messages": [reply]}

It’s the same retrieve-then-generate chain from Act 1, dropped in as a graph node — the retriever being a Runnable is what lets it slot in unchanged. Recommendations is the path where you want the model’s fluency and where a wrong answer is cheap, so it’s fully model-driven. That’s the low-trust-required end of the gradient, the opposite of the refund gate — and it’s the same system holding both, which is exactly the point.

Wiring it together

from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver

g = StateGraph(State)
g.add_node("supervisor", supervisor)
g.add_node("orders", orders)
g.add_node("refund", refund)
g.add_node("recommendations", recommendations)
g.add_edge(START, "supervisor")
for node in ["orders", "refund", "recommendations"]:
    g.add_edge(node, END)

app = g.compile(checkpointer=InMemorySaver())

The checkpointer gives the whole system per-thread memory — one thread_id per customer conversation — and it’s what lets the refund interrupt pause and resume across separate requests (the approval can come minutes later, from a different HTTP call). Because the result is a plain compiled graph, it streams: stream_mode="updates" narrates “routing… querying orders… composing answer,” and stream_mode="messages" streams the reply token by token.

Following a request through

Trace one customer message — “I want a refund on order A17” — through the compiled graph to see the pieces move:

  1. invoke with the message on thread cust-42. The checkpointer loads any prior state for that thread and appends the new message.
  2. supervisor runs, sees “refund” in the text, returns Command(update={"intent": "refund"}, goto="refund"). State now records the intent; control transfers.
  3. refund runs and hits interrupt(...). The graph suspendsinvoke returns with __interrupt__ carrying the refund details, and the checkpoint saves the paused state. Nothing has been refunded.
  4. Your app shows a human the payload. Minutes later, a separate call: invoke(Command(resume="yes"), config) on the same thread. The graph reloads the paused checkpoint, interrupt returns "yes", the node processes the refund and replies.

Every step is a primitive you built: a handoff, a gate, a checkpoint, a resume. That’s the whole system, and it’s why the capstone feels less like new material than like a reunion.

Extending it

Because it’s a graph, growing it is adding nodes. A shipping specialist? Add a shipping node with its own tools and a "shipping" destination in the supervisor’s Literal and routing. An escalation path to a human agent for anything the model flags? Another node, another gate. A real refund API behind the gate? Swap the process_refund call. Nothing you’d add requires touching what’s there — new capability is a new node and an edge, which is the modularity argument from Chapter 12 paying off in the large.

What each chapter contributed

Standing back, the capstone is a map of the series:

There’s no primitive in the capstone you haven’t seen. That’s the intended feeling: a “real” multi-agent system is not a new category of thing, it’s these pieces, composed with judgment about which paths trust the model and which don’t.

Final thoughts

The bookshop support agent is the series made concrete: a supervisor triaging to an orders agent that runs verified SQL against the warehouse, a recommendations agent backed by retrieval, and a refund path gated behind a human, all persistent and streamable, all run end to end. The one design lesson worth taking beyond LangGraph: route consequential decisions with rules and gates, route fuzzy ones with the model, and never let the model skip a gate that guards something irreversible. You can copy this graph and grow it a node at a time. It’s a starting point, and it works. Two chapters remain, on making a system like this observable and shippable.

Next: observability and evaluation — tracing a multi-agent run, evaluating it, and testing graphs with fake models so your tests don’t need a live LLM.

Comments