interrupt(): human-in-the-loop that doesn't feel bolted on
In June the triage client asked for the next step: let the agent issue account credits for verified billing mistakes. Real money, moving from a graph node into the billing provider. I said no to full autonomy in the first meeting. Not because the model was bad at the task. Nobody in the room could tell me who signs off a wrong credit.
What we shipped instead is an approval gate built on interrupt(), and it's the first human-in-the-loop setup I've made that doesn't feel like scaffolding taped to the side of an agent.
the shape of the pattern
A node computes the proposed credit (amount, reason, links to evidence) and calls interrupt() with that payload. The run stops. Not crashed, not sleeping on a thread: checkpointed and exited, as cleanly as if the graph had finished. Whenever a human answers, four minutes later or four days, you resume the thread with their decision and the interrupt call returns it inside the node as though the pause never happened.
def approval_gate(state: CreditState):
decision = interrupt({
"amount": state["proposed_credit"],
"reason": state["reason"],
"ticket": state["ticket_id"],
})
return {"approved": decision["approved"]}
Because the pause is just a checkpoint, it survives deploys and the approver's vacation. That used to be the hard part: my pre-LangGraph attempts involved polling tables and a status column that accumulated values like PENDING_2 and PENDING_FINAL. All of that dissolved.
resume re-runs the node, and it will bite you
One mechanic to internalize early: on resume, the node containing the interrupt executes again from its top, at least in the builds I used this spring. Code above the interrupt call runs twice. The approvers taught me this one: I'd put the Slack notification inside the gate node, above the interrupt, so every approval pinged the approver a second time after they'd already answered. It took me a day and a half to connect the duplicate pings to resume semantics.
Side effects go in their own node, upstream of the gate, full stop.
the primitive is clean, the product is on you
The framework gives you the payload out and the resume hook back, and that's the whole gift.
interrupt() hands you a perfect pause; everything that makes the pause humane is still yours to build.
The queue an approver works through, the screen with evidence side by side, the audit trail of who approved what: I built all of it as a plain FastAPI page, one table and two buttons, about 340 lines including the audit log. It took most of a week, longer than wiring the graph itself, and I'd budget that way again. The real design questions (what the human sees, what fits on one screen, what a safe default is) are the substance of human-in-the-loop design, and no primitive answers them for you. Once you notice the gate pattern it's everywhere; Claude Code's permission prompt is the same move with a different skin.
abandonment, learned the hard way
Three weeks in, I counted threads sitting inside an interrupt and found 27 older than a week. One approver was on vacation, nobody watched queue depth, customers waited. A pause that survives anything, it turns out, also survives being forgotten.
The fix isn't clever: a sweeper pages a backup approver at 48 hours, auto-decline lands at seven days with a follow-up task attached, and the support lead now has one dashboard number she actually checks. We argued hardest about the default (auto-approve would have been kinder to customers) and chose decline, because a wrong refusal costs an apology while a wrong credit costs money. I still think that's right. I still don't love it.
Since going live in mid-June the gate has cleared roughly 380 credits, declined 9, and caught two genuine mistakes: one duplicate refund and one currency mix-up. Cheap insurance, honestly priced.