Human in the loop
Pausing a graph mid-run, showing a person what is about to happen, and resuming with their answer. In a regulated domain this is the feature that makes an agent shippable at all.
The mechanism
from langgraph.types import interrupt, Command
def approve(state: State) -> dict:
decision = interrupt("Approve this refund?")
return {"approved": decision}interrupt() stops the graph at that point. The value you pass is
surfaced to the caller; the graph’s state is checkpointed; the process can
exit entirely.
Resuming supplies the value that interrupt() returns:
config = {"configurable": {"thread_id": "refund-9"}}
graph.invoke({"amount": 500}, config) # pauses here
# ...minutes or days later, possibly another process
graph.invoke(Command(resume=True), config)Command(resume=True) makes interrupt(...) return True and execution
continues from that point — not from the start of the graph.
A checkpointer is mandatory. Without one there is nothing to resume from, and this is the single most common setup error. See Persistence and threads.
Why this is not a callback
The pause is durable. Your API can return a 202, the worker can shut down, and someone can approve the action tomorrow from a different machine — the state is a row in Postgres, not a suspended coroutine.
That is what makes it viable for real approval workflows, where the human is a compliance officer with a queue, not a user staring at a spinner.
The approval pattern
from typing import Literal
from langgraph.types import interrupt, Command
Outcome = Literal["execute", "cancel"]
def approve(state: State) -> Command[Outcome]:
ok = interrupt({
"action": "refund",
"amount": state["amount"],
"customer": state["customer_id"],
})
return Command(goto="execute" if ok else "cancel")Two things worth copying here. The interrupt payload is a dict describing
what is about to happen — the reviewer needs the details, not a yes/no
prompt with no context. And the node returns a Command, so the decision
routes directly rather than being stashed in state for a separate router.
Review and edit
The resume value is not limited to a boolean. Pass back edited content and the node continues with it:
def review(state: State) -> dict:
edited = interrupt({
"instruction": "Review before sending",
"draft": state["draft"],
})
return {"draft": edited}This is the shape for anything where a human corrects the model rather than just gating it — and the corrections are worth capturing as eval cases, since they are labelled examples of the model being wrong. See Building eval sets.
The rules that keep resume correct
Resuming re-executes the node from its start. Everything before the
interrupt() call runs a second time. That single fact generates all the
rules:
| Do | Do not |
|---|---|
| Put side effects after | charge a card before |
| Keep call order fixed | skip an interrupt conditionally |
| Split complex nodes | loop with varying logic |
| Let it raise | wrap in bare except |
Side effects before the interrupt run twice. If the node emails the customer and then asks for approval, the customer gets two emails. Move the effect after the pause, or make it idempotent — the same discipline as Idempotency Keys (API-Level Deep Dive).
Gotcha:
interrupt()works by raising a control exception. A baretry/exceptaround it swallows the pause and the graph runs straight through the gate. This is a silent failure of an approval control, which in FinTech is the worst category of bug — catch specific exceptions, never bare.
Conditionally skipping an interrupt breaks resume indexing. The runtime matches resume values to interrupts by their order within the node, so a node that sometimes calls one interrupt and sometimes two will resume into the wrong one. Keep the call sequence deterministic; branch by routing to a different node instead.
Where to put the gate
The design question, not a mechanical one: gate on the action, not on the turn. Interrupting before every model call trains reviewers to click approve without reading, which is the rubber-stamping failure.
Gate on what is hard to reverse — money moving, a message sending, a record deleting. Everything else runs unattended. That is also the answer to “how do you make oversight meaningful”: a low volume of high-consequence decisions, with the disagreement rate monitored.
Related
Interview angle 5
- “How do you add human approval to an agent?” -
interrupt()inside a node pauses the graph and checkpoints state;Command(resume=value)continues from that point with the value as the interrupt’s return. A checkpointer is mandatory — that is the usual setup error. - “Is the pause a blocked process?” - no, and that is the point. State is a row in the database, so the worker can exit and someone can approve days later from another machine. That is what makes it usable for a compliance queue rather than a spinner.
- “What breaks on resume?” - the node re-executes from its start, so anything before the interrupt runs twice. Side effects belong after the pause or must be idempotent, or the customer gets two emails.
- “What’s the subtle failure?” - wrapping
interrupt()in a baretry/except. It works by raising a control exception, so a broad catch swallows the pause and the graph runs straight through the approval gate silently. - “Where do you put the gate?” - on hard-to-reverse actions, not on every turn. Interrupting constantly produces rubber-stamping, which is worse than no gate because it looks like oversight in an audit.