Human-in-the-Loop: Pausing for Approval
Some actions are too consequential to let an agent take alone. interrupt() pauses a graph mid-run, surfaces a payload, and waits; Command(resume=...) continues it. Approve, edit, or reject — plus interrupt_before and the middleware that adds a gate to a prebuilt agent. All built on checkpointing.
An agent that can issue refunds, send emails, delete records, or run SQL that changes data is useful and alarming in equal measure. The alarming part has a fix: for the consequential steps, you keep a human in the loop. The graph works up to the decision, stops, shows a person exactly what it’s about to do, and waits for a yes. This is the feature that turns “an agent that might do something expensive” into “an agent that proposes, and a human commits” — and it’s what makes an agent safe to point at anything irreversible. LangGraph builds it entirely on the checkpointing from the last chapter: a pause is just a checkpoint whose next step is “wait for a human.” Run against langgraph 1.2.9.
interrupt pauses the graph
Inside a node, call interrupt(payload). It stops the graph, hands payload back to whoever called invoke, and suspends — the node does not finish:
from langgraph.types import interrupt
def approval(state):
decision = interrupt({"action": "refund", "amount": state["amount"]})
return {"log": [f"decided: {decision}"]}
Two things happen at that interrupt line. First, execution halts — nothing after it runs yet. Second, the payload travels out to your code, which is now responsible for getting a human decision through whatever channel your app uses — a UI, a Slack message, an email approval link. When you resume, interrupt returns the human’s answer, and the node continues from that line as if the call had simply returned a value. It reads like a blocking prompt, and thanks to persistence it can block for a millisecond or a week: the suspended state is a checkpoint, so the process can even restart while the graph waits.
This requires a checkpointer — the pause has to be saved, so interrupt only works on a graph compiled with checkpointer=.... Without one, there’s nowhere to store the suspended state, and it’s an error.
Seeing the pause, and resuming
When a graph hits an interrupt, invoke returns with the payload under an __interrupt__ key instead of a finished result:
config = {"configurable": {"thread_id": "order-A17"}}
result = app.invoke({"amount": 30}, config)
result["__interrupt__"]
# [Interrupt(value={'action': 'refund', 'amount': 30}, id='14d4...')]
The graph is now parked, and you can confirm it with the state inspection from the last chapter — it’s paused at the approval node:
app.get_state(config).next # ('approval',) — the node waiting to resume
To continue, invoke again with a Command(resume=...), and the value you pass becomes the return value of the interrupt call:
from langgraph.types import Command
app.invoke(Command(resume="yes"), config)
# the approval node's `decision` is now "yes"; the graph runs to completion
Verified end to end: the first invoke paused at approval with the payload; get_state(...).next was ('approval',); and Command(resume="yes") resumed, with interrupt returning "yes" inside the node. The human’s decision flowed into the running graph through the value you resumed with — no shared variable, no callback, just a value handed back to a suspended function.
Approve, edit, reject, ask
The resume value is arbitrary, and that’s what lets one primitive cover every human-in-the-loop shape. The difference between them is only what you put in the payload and what the node does with the answer:
Approve / reject — resume with a decision the node branches on:
def issue_refund(state):
approved = interrupt({"action": "refund", "amount": state["amount"]})
if approved != "yes":
return {"messages": [AIMessage("Refund cancelled by an agent.")]}
process_refund(state["amount"])
return {"messages": [AIMessage(f"Refunded ${state['amount']}.")]}
Edit — surface a draft, let the human return a corrected version, and use that:
def draft_reply(state):
draft = write_draft(state)
final = interrupt({"draft": draft}) # human returns an edited string
return {"messages": [AIMessage(final)]} # send the human's version, not the draft
Resume with Command(resume="the edited text") and final becomes the human’s version. (When you’d rather edit the state directly than route the correction through a resume value, update_state is the alternative.)
Ask — interrupt to request missing information (“which shipping address?”), resume with the answer, continue. Same mechanism, different payload.
All three are one move — pause, surface a payload, resume with a value — which is why “human-in-the-loop” in LangGraph is a single function to learn rather than a subsystem.
Why the gate is safe
The safety property is worth stating precisely, because it’s stronger than a prompt instruction. In the refund node above, the model can drive the whole conversation, gather the order and amount, and route to this node — but the money never moves until a person, looking at the surfaced payload, resumes with "yes". The agent literally cannot skip the gate, because the node suspends on the interrupt call; execution does not proceed past that line without a resume. Contrast this with “the system prompt says always ask before refunding,” which a model can ignore, misread, or be talked out of. A prompt is a request; an interrupt is a wall. For anything irreversible, you want the wall.
interrupt_before: a gate without editing the node
There’s a second, coarser mechanism. Instead of calling interrupt inside a node, you tell compile to always pause before (or after) certain nodes:
app = builder.compile(checkpointer=saver, interrupt_before=["issue_refund"])
This pauses the graph before issue_refund runs at all, letting you inspect and optionally update_state, then resume with a bare Command(resume=None). Verified: with interrupt_before=["n"], the first invoke paused at ('n',), and resuming with app.invoke(None, config) ran the node. It’s a blunter tool — it fires every time that node is reached, carries no payload of its own, and doesn’t need you to touch the node’s code — which makes it handy for debugging or a blanket “review before every risky node” policy. For real, targeted human-in-the-loop, the in-node interrupt is usually better, because it carries a payload and fires exactly when the logic decides, not merely at a node boundary. Use interrupt_before to review, interrupt to gate.
Adding a gate to a prebuilt agent
You don’t have to hand-build a graph to get this. Because a create_agent is a compiled graph, the same interrupt machinery works on it — and LangChain ships a HumanInTheLoopMiddleware that wires an approval gate around an agent’s tool calls without you editing anything. That’s the prebuilt path: for a hand-built graph, place interrupt where the decision is; for a prebuilt agent, add the middleware. Both suspend on the same checkpoint mechanism, so both resume with Command(resume=...).
Final thoughts
Human-in-the-loop is one function and one resume. interrupt(payload) parks the graph and hands the payload out; Command(resume=value) continues it, delivering value back into the node — and because a pause is a checkpoint, the wait can outlast the process. Approve, edit, and ask are the same move with different payloads; interrupt_before gives a code-free review gate; and HumanInTheLoopMiddleware adds one to a prebuilt agent. The property that matters is that the gate is structural — the node suspends, so the agent cannot route around it, which is exactly what you need on anything irreversible. With state, routing, memory, and now a pause button, the graph is genuinely deployable. One capability rounds out the core: showing the work as it happens.
Next: streaming — the stream modes that surface tokens, state, and per-node progress as a graph runs, instead of making the user wait for the end.
Comments