Adding human approval to a LangGraph workflow used to mean hand-writing a three-node graph around interrupt() and being careful about what ran before the pause. LangChain now ships that gate as middleware, and it removes the duplicate-write bug by construction, so the hand-written version is only for gates that are not tool calls. Here is where the line falls, what still replays on resume, and the traps on each side. This post was first published on 10 May 2026 and rewritten on 29 August 2026, when the middleware changed the answer.
What the middleware took over
LangChain ships HumanInTheLoopMiddleware, which per the human-in-the-loop documentation “lets you add human oversight to agent tool calls”. You declare which tools need review and which decisions each one accepts:
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(
model="...",
tools=[write_file, execute_sql, read_data],
middleware=[
HumanInTheLoopMiddleware(
interrupt_on={
"write_file": True,
"execute_sql": {
"allowed_decisions": ["approve", "reject"],
},
"read_data": False,
},
),
],
checkpointer=InMemorySaver(),
)
There are 4 decision types: approve, edit, reject and respond. You resume by passing them back on the same thread:
agent.invoke(
Command(resume={"decisions": [{"type": "approve"}]}),
config=config,
)
The important part is not the ergonomics, it is where the middleware puts the gate. It interrupts before the tool runs, so the side effect cannot happen until the decision is in. That is exactly the fix the original version of this post told you to implement by hand, and you now get it by declaring a policy. Everything below is verified against LangChain 1.3 and LangGraph 1.2, the versions current at the end of August 2026.
When you still write interrupt() yourself
The middleware governs tool calls inside an agent. Plenty of approval gates are not tool calls: a stage boundary in a pipeline, a threshold breach, a document that needs sign-off before the next step, anything in a graph you wrote rather than an agent you configured. For those, interrupt() is still the primitive, and it still needs a checkpointer and a stable thread_id.
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
def approval_gate(state: RefundState) -> dict:
return {"approved": interrupt(state["approval_payload"])}
def commit_refund(state: RefundState) -> dict:
payments.refund(
order_id=state["order_id"],
amount_cents=state["amount_cents"],
idempotency_key=state["operation_id"],
)
return {"status": "approved"}
The shape that works is unchanged: prepare the payload, pause, and put the mutation in its own node after the gate, guarded by an idempotency key. What changed is that this is now the minority case rather than the default one.
Why a node replays from the top on resume
The behaviour that explains most interrupt bugs is still true, and is no longer folklore. The interrupts documentation states it directly: “The node restarts from the beginning of the node where the interrupt was called when resumed, so any code before the interrupt runs again.”
So “side effects called before interrupt should (ideally) be idempotent”, in the docs’ words, and the example they use to illustrate it is a create_audit_log call that inserts a duplicate row on every resume. This used to be a lesson teams learned in production. It is now the officially documented failure mode, which is a good reason to stop treating it as tribal knowledge and start treating it as a review checklist item.
Three traps that are easy to ship
A loop around interrupt() replays quadratically. Because the node restarts each time, a while True loop that calls interrupt() repeatedly re-executes every prior iteration on every resume. The docs are blunt about the cost: “the first resume replays 1 iteration, the second replays 2, and so on”. That is triangular growth, so ten resumes replay 55 iterations of whatever sits in the loop body. If you need several questions, use several nodes, or several parallel interrupts.
Parallel branches resume by id, not by value. When two branches pause at once, you map each interrupt’s id to its answer rather than passing a bare value:
resume_map = {
i.id: f"answer for {i.value}"
for i in stream.interrupts
}
graph.stream_events(
Command(resume=resume_map), config, version="v3"
)
The third trap belongs to the middleware rather than to interrupt(). The respond decision returns the human’s message as a synthetic tool result, so the agent reads it as success. The documentation is explicit: “Do not use respond to deny side-effecting tools, because its message is treated as a successful tool result.” Denying an action is reject. Getting this backwards produces an agent that believes it sent the email.
Durability: the pause always persists, the run does not
One clarification worth making, because it is easy to over-worry. LangGraph now exposes three durability modes, and all three persist on a human-in-the-loop interrupt, so the pause itself survives in every mode. What the modes change is whether you can recover from a crash between checkpoints mid-run.
“LangGraph writes checkpoints in the background by default”, which is the async mode: good performance, with a small window where a crash loses the last write. When a run is long and expensive enough that losing it matters, set "sync", where “LangGraph persists changes synchronously before the next step starts”, at a performance cost.
graph.stream({"input": "..."}, durability="sync")
Also worth knowing if you are copying older code: graph.invoke() still surfaces a pause under result["__interrupt__"], but the docs now steer you to graph.stream_events(..., version="v3"), where the payloads arrive on stream.interrupts.
Choosing between the two, in one line each
- Approving an agent’s tool calls? Use
HumanInTheLoopMiddleware, and let it place the gate. This is now the default, not the fallback. - Gating something that is not a tool call? Write
interrupt()yourself, with a durable checkpointer and a stablethread_id. See our confidence gating post for choosing the signal that triggers the gate in the first place. - Either way, audit what runs before the pause. The node replays from the top. That is the one rule that did not change.
- Never use
respondto deny an action. It reads as success.
The larger point is the one we keep hitting in deep agents in production: frameworks absorb patterns fast, and the pattern you hand-rolled last quarter is often a configuration flag this quarter. What they do not absorb is the human-in-the-loop design question underneath, which is deciding what a person is actually accountable for approving. LangGraph will hold the state for as long as you need. It will not tell you where the gate belongs.
Sources
- LangGraph interrupts documentation, on node restart, idempotency and parallel interrupts
- LangChain human-in-the-loop guide, on the middleware and its four decision types
- LangChain built-in middleware reference, for the
interrupt_onpolicy shape - LangGraph checkpointers documentation, on the three durability modes
- Thinking in LangGraph, on background checkpoint writes and node granularity
- The langgraph package on PyPI, for the release dates behind the version pins above
Last updated: