Every agent that writes to the world eventually gets a human in front of the write. Refunds, outbound email, ticket closure, a DELETE against a customer record — somebody decides that the model should propose and a person should approve. That decision is easy. The implementation is where teams lose two weeks.
The usual first attempt is a confirmation prompt in the UI: the agent returns a proposed action, the frontend shows a modal, the user clicks yes, and the frontend calls a second endpoint that performs the action. It demos fine. Then the approver goes to lunch mid-run, the service redeploys, the agent's in-memory state is gone, and the click resolves into nothing. Or the approver says yes to a payload that no longer matches what the agent will actually execute, because the tool arguments were regenerated after approval.
Human-in-the-loop is a durability problem and a provenance problem before it is a UX problem. Three things have to hold: the run pauses without holding a process open, the thing approved is exactly the thing executed, and the decision is recorded somewhere you can audit later.
Interrupt at the action, not at the turn
LangGraph's interrupt suspends a graph mid-node, persists state to the checkpointer, and surfaces a payload to the caller. Resume happens later — different process, different machine, different week — by invoking the same thread with a Command(resume=...). That is the primitive. Where you put it matters more than how you call it.
Place the interrupt immediately before the side effect, inside the node that performs it. Not at the end of the planning step, not in a separate "review" node three hops upstream. The gap between approval and execution is where drift lives. If the model proposes refund(order_id=1182, amount=240.00), the approver should be reviewing that exact argument dictionary, and the code that resumes should execute that same dictionary — not re-call the model to regenerate arguments after the human says yes.
Concretely: build the tool call, serialize the arguments, interrupt with them, and on resume execute the serialized version. If your design regenerates anything post-approval, you have an approval theater, not an approval gate.
Decide what needs a human, in code
Asking the model whether an action is risky enough to need review is a policy you cannot test. Write the policy as a predicate over the proposed call:
- Always-review tools: anything irreversible, externally visible, or financial. A fixed allowlist of tool names.
- Threshold rules: refund over $100, email to more than 20 recipients, bulk update touching more than N rows.
- Provenance rules: any write whose arguments were derived from untrusted retrieved text, not from the user's own request.
- Confidence or novelty rules, used sparingly: first time this agent has touched this account, unusual tool sequence.
Keep the predicate in one module. It is the thing auditors ask about and the thing you will tune for months, because the real failure is not an unreviewed bad action — it is review fatigue. An agent that interrupts on everything trains its approvers to click through, and a rubber-stamped approval is worse than no approval, because it manufactures a record that someone looked.
Start strict, then measure the override rate. If a tool has been auto-approved by humans 200 times out of 200, that tool does not need a gate; it needs a post-hoc audit log and an undo path.
Give the reviewer enough context to say no
The interrupt payload is a user interface. It needs three things: the proposed action in plain terms, the evidence behind it, and the available responses.
Evidence is the part teams skip. A reviewer looking at close_ticket(id=4471) cannot tell whether that is right. A reviewer looking at the customer's last message, the retrieved policy passage the agent cited, and the two prior tool results can. Pass a small, curated bundle — not the whole message history.
Offer at least three responses, because approve/reject is too coarse:
- Accept — execute as proposed.
- Edit — the human corrects the arguments, and the edited payload executes. This is the response that gets used most, and the one that produces your best training and eval data.
- Reject with reason — the agent resumes with the rejection as an observation and may replan, or may stop.
Treat an edit as a labeled example of a model failure. Store the before/after pair. Six weeks of edits is a dataset that tells you precisely which argument fields the model gets wrong, and that is usually a prompt or schema fix, not a model problem.
Make resume survive a deploy
The interrupt only works if the checkpointer is durable. In-memory savers are for tests. In production, that means Postgres, with a real connection pool, and thread IDs you can map back to a business entity — a ticket, an order, a conversation — so an operator can find a stuck run without a trace viewer.
The parts that break in practice:
- Schema drift. A paused run resumes into whatever code is deployed next week. If you renamed a state field or changed a node name in between, resume fails on a thread you cannot replay. Version your state schema and keep resume tolerant of old shapes for at least one deploy cycle.
- Non-serializable state. A client object or an open file handle in state will serialize once and fail on restore. Keep state to plain data.
- Double execution. Approvals get clicked twice, retried by a flaky network, or replayed by a queue. Make the executing tool idempotent on a key derived from the thread and the call, or you will issue two refunds for one approval.
- Lost threads. Nothing in the framework tells a human that a run has been waiting four days. That is your job.
Timeouts and escalation are part of the design
Every paused run needs an answer to: what happens if nobody responds? Pick one per action class and implement it, rather than discovering the default by accident.
Sweep paused threads on a schedule. Reminders at an hour, escalation to a second approver at a day, and either auto-reject or auto-expire at some horizon. Auto-approve on timeout is almost never right for the actions worth gating; if it feels right, the gate was unnecessary.
Also decide what the end user sees while waiting. A chat UI that silently stalls reads as broken. "Waiting on an agent review, typically under an hour" costs one line of state and removes most of the support tickets.
Evaluate the loop, not just the agent
The review step changes the system's behavior, so it belongs in your evals. Three things worth measuring:
Gate precision and recall. Over a set of recorded runs, how many actions that should have been reviewed actually triggered an interrupt, and how many interrupts were unnecessary? Both are tunable, and both drift as you add tools.
Resume correctness. Write tests that pause a run, serialize, restart the process, and resume. Include an edited payload and a rejection. This is the test suite that catches the schema-drift bug before a customer does.
Override rate per tool. The share of interrupts where the human changed or rejected the proposal. A tool at 40% is a model-quality problem to fix. A tool at 0.5% over hundreds of runs is a gate to remove. Both are findings.
The honest trade-off
A human in the loop buys you safety and an audit trail, and it costs you latency, an operations surface, and a queue somebody has to staff. It is not free, and it is not a substitute for making the agent correct — it caps the blast radius of errors you have not fixed yet.
The design that holds up: a narrow, code-defined set of gated actions; an interrupt placed at the side effect; payloads that execute exactly as approved; durable checkpoints with idempotent tools; timeouts with real escalation; and an override rate you watch like any other metric. Build that, and you can widen the agent's permissions over time with evidence instead of nerve.
If you are adding approvals to an agent — or debugging resume paths that lose runs — tell us what the agent is allowed to do and where it stalls. We will reply with questions.