Overview
Support teams at a B2B SaaS client were manually triaging ~1,200 tickets a day across billing, technical, and account-management queues. Misrouted tickets bounced between teams for an average of 4.5 hours before landing with the right owner. The goal was not to auto-resolve tickets — it was to classify, enrich, and route them with enough context that the receiving agent could act immediately.
I built a supervisor/worker topology in LangGraph: a lightweight supervisor classifies intent and dispatches to one of four specialist agents (billing, technical, account, escalation). Each worker has its own tool belt — billing can query Stripe and the invoicing DB, technical can search the error-log index, and so on. Workers return a structured triage packet (priority, owner queue, suggested first reply, extracted entities) that gets written back to Zendesk. Humans stay in the loop for anything the supervisor flags below a confidence threshold.
Architecture
- Zendesk webhook pushes new tickets onto a Redis stream; a FastAPI consumer batches them into the LangGraph runtime.
- Supervisor node performs intent classification with a constrained JSON schema output, then routes via conditional edges to one of four worker subgraphs.
- Each worker agent has 3-5 scoped tools (Stripe lookup, log search, CRM fetch) and a hard budget of 6 tool-call iterations before forced handoff.
- Shared state is a typed TicketState object checkpointed to Postgres via LangGraph's checkpointer, so any run can be replayed or resumed.
- Low-confidence classifications (< 0.8) short-circuit to a human-review queue instead of guessing — the escalation path is a first-class graph node, not an error handler.
- Every run is traced in LangSmith with ticket ID as metadata; a nightly job diffs agent-assigned queues against final human-assigned queues to measure routing accuracy.
Code Snippets
class TicketState(TypedDict):
ticket_id: str
messages: Annotated[list[AnyMessage], add_messages]
intent: str | None
confidence: float
triage: TriagePacket | None
def route_ticket(state: TicketState) -> str:
if state["confidence"] < 0.8:
return "human_review"
return state["intent"] # "billing" | "technical" | "account"
graph = StateGraph(TicketState)
graph.add_node("supervisor", classify_intent)
graph.add_node("billing", billing_agent)
graph.add_node("technical", technical_agent)
graph.add_node("account", account_agent)
graph.add_node("human_review", enqueue_for_human)
graph.add_conditional_edges("supervisor", route_ticket)
graph.set_entry_point("supervisor")
app = graph.compile(checkpointer=PostgresSaver(pool))MAX_TOOL_ITERATIONS = 6
def billing_agent(state: TicketState) -> dict:
agent = create_react_agent(
model=sonnet,
tools=[lookup_stripe_customer, fetch_invoices, get_refund_policy],
response_format=TriagePacket,
)
result = agent.invoke(
{"messages": state["messages"]},
config={"recursion_limit": MAX_TOOL_ITERATIONS * 2},
)
packet = result["structured_response"]
if packet.priority == "urgent" and not packet.evidence:
# Never let an agent mark urgent without citing a source.
packet.priority = "high"
packet.notes.append("Downgraded: urgent claim lacked evidence.")
return {"triage": packet}Lessons Learned
- Routing accuracy came from the classification schema, not the model. Forcing the supervisor to emit one of four enum values with a confidence score beat free-text classification by 11 points on our golden set.
- The human-review path must be a designed node, not a fallback. Once we treated 'I don't know' as a legitimate output, hallucinated routes nearly disappeared.
- Tool-call budgets are non-negotiable in production. One runaway billing agent burned 40k tokens re-querying Stripe pagination before we capped iterations.
- Measuring against final human-assigned queues (not agent self-reports) was the only honest accuracy metric. Self-graded evals overstated performance by ~8%.
- Checkpointing to Postgres paid for itself the first week — replaying a bad run with a fixed prompt is infinitely better than reconstructing it from logs.