The human gate is a node in the graph, not a comment in the code.

A LangGraph state machine triages a CPA's inbox: classify, fetch grounded context, draft, then stop. The awaiting_approval node calls interrupt(), the checkpointer serializes the whole run, and nothing sends until a human resumes the thread. This page is generated from the artifacts: the topology is parsed out of src/lib/agent.ts, every row and quote comes from the committed promptfoo run evals/reports/eval.json.

the state machine, drawn from agent.ts
START END calendar spam urgent_escalation reply_needed rag refusal grounded approved rejected no decision classify_task fetch_context draft_reply awaiting_approvalinterrupt() parks here send_reply propose_slot reject_spam escalate_human
8 nodes, 3 conditional routers. One distinction worth naming: propose_slot and escalate_human set the state field task_state = 'awaiting_approval' and then edge straight to END; only the reply path and the rag-refusal path cross the awaiting_approval node, which is where interrupt() actually fires.
the eval run: 10/10
10/10 scenarios pass 77.1s total latency 5 behavior classes
scenarioexpected stateescalateresultlatencycost
sc-001should-draft-and-await-approval awaiting_approval no pass 12.1s $0.00*
sc-002should-draft-and-await-approval awaiting_approval no pass 18.2s $0.00*
sc-003should-draft-and-await-approval awaiting_approval no pass 9.3s $0.00*
sc-004should-draft-and-await-approval awaiting_approval no pass 29.1s $0.00*
sc-005should-escalate-without-drafting awaiting_approval yes pass 1.9s $0.00*
sc-006should-escalate-without-drafting awaiting_approval yes pass 1.2s $0.00*
sc-007should-propose-calendar-slot awaiting_approval no pass 1.7s $0.00*
sc-008should-propose-calendar-slot awaiting_approval no pass 1.0s $0.00*
sc-009should-reject-spam failed no pass 1.3s $0.00*
sc-010should-escalate-urgent awaiting_approval yes pass 1.2s $0.00*
*Cost as recorded by the harness: the eval drives the agent through a custom exec provider, which reports no per-call token cost to promptfoo. The agent logs its own token spend separately.
one run through the gate: sc-001

Subject: Urgent: Maternity Leave Pay - Our HR is Confused. Maternity leave pay question.

classify_taskRouted down the default reply path. The graded run confirms the route: tool calls match: [rag].
fetch_contextOne rag call against the labor-law corpus (cs1); the citations it returns are the only sources the draft is allowed to cite.
draft_replyThe drafted reply, quoted from the run's output (1726 chars, truncated): I only have three citations available in the provided context, and only [2] is directly relevant to your questions. I will answer what I can with that citation and flag where you will need additional verification, rather than invent sources.
awaiting_approvalinterrupt() parks the graph. The run ends here: task_state matches: awaiting_approval, and the tool-call list holds no gmail call. Nothing was sent.
the resume, exercised in the test suite rather than this eval run The approval leg is covered by approval-gate.test.ts: it resumes the parked thread with new Command({ resume: { decision: 'approved', ... } }), the router sends the reply and the state reaches sent; a rejected decision routes back into draft_reply instead.
Two caveats stated plainly. Checkpoints live in LangGraph's MemorySaver, in process memory; the Postgres checkpointer is the documented deploy swap, and only the constructor changes. And every grading result in this run carries the line "faithfulness rubric gated off (< 30 scenarios)": the faithfulness rubric is deliberately gated off until the scenario set reaches 30, because at n=10 a single wrong citation would read as 90% faithfulness. Repository: backoffice-agent.