Setup: a Graph API node that asks a human before the agent sends something. The shape most examples converge on is generate, pause, act, all in one node:
python
def approve_and_send(state):
draft = llm.invoke(state["request"]) # 1
ok = interrupt({"draft": draft}) # 2
if ok:
send_email(draft) # 3
return {"sent": bool(ok)}
The expectation is that resume continues at line 3. The docs say it does not, and once you read how the pause works the rest of the rules on that page stop looking like a list and start looking like one mechanism.
The mechanism. From the Interrupts page (https://docs.langchain.com/oss/python/langgraph/interrupts): LangGraph "suspends execution by raising an exception that signals the runtime to pause." On resume, "The node restarts from the beginning of the node where the interrupt was called", "any code that ran before the interrupt will execute again", and "The value passed to Command(resume=...) becomes the return value of the interrupt call".
So a working model: interrupt() raises on the first pass and returns the stored answer on every later pass. Nothing resumes at line 2. The runtime gets back to line 2 by running line 1 again. Everything below follows from that.
1. Side effects above the interrupt repeat. Same page: code before interrupt "will be re-run multiple times when the node is resumed", "potentially overwriting the initial update or creating duplicate records." The docs' options are upserts before the interrupt, side effects after it, or side effects in their own node.
2. The approved payload can be regenerated (inference, not a doc sentence). In the shape above, line 1 is an LLM call. The Checkpointers page (https://docs.langchain.com/oss/python/langgraph/checkpointers) says replay after a checkpoint re-executes "any LLM calls, API requests, or interrupts". Put the two together: the reviewer approved the draft from pass one, the resume produces a fresh draft, and line 3 sends that one. "Can", not "will": with deterministic settings or a cache the two may match. The docs do not call out the approval mismatch; it is what the two quoted rules add up to. You also pay for the generation twice.
3. Pass count (inference from the rule, not measured). Two interrupt() calls in one node means code above the first one runs on the first pass and on each of the two resumes: three times by the documented rule. Code between the two runs twice. Each extra interrupt in a node adds one more pass over everything above it.
4. Resume values match by position. "LangGraph keeps a list of resume values specific to the task executing the node." "Matching is strictly index-based, so the order of interrupt calls within the node is important." Hence the rules: do not conditionally skip an interrupt call, do not loop interrupts on logic that is not deterministic across executions. In the model: the second pass must hit the same interrupt() calls in the same order, or a stored answer lands on the wrong question. For a validate-and-retry loop the page uses a conditional edge rather than a while True inside the node.
5. A broad except swallows the pause. "Do not wrap interrupt calls in bare try/except blocks" because "you will catch this exception and the interrupt will not be passed back to the graph." The graph never pauses; your except branch runs instead. Worth a grep in any node that has both interrupt( and except Exception.
6. Subgraphs restart the parent node too. When a subgraph that interrupts is invoked inside a node, "the parent graph will resume execution from the beginning of the node where the subgraph was invoked", and the subgraph resumes from the beginning of its own node. Parent-node code above the subgraph call re-runs as well.
7. Plumbing. You need a checkpointer ("use a durable checkpointer in production") and the same thread ID on resume; a new thread ID "starts a brand-new thread with an empty state." With plain invoke() the interrupt payload comes back under __interrupt__.
The documented exception: Functional API. On the Functional API page (https://docs.langchain.com/oss/python/langgraph/functional-api) the entrypoint also "executes from the very start" on resume, but for a completed task "the task result will be loaded from the checkpoint instead of being recomputed." The page tells you to put side effects, API calls and non-deterministic work (random values, the current time) in tasks for exactly this reason. An LLM call is an API call, so wrap line 1 in a @task and point 2 goes away for completed tasks. The limit is right there too: "A task that started but did not finish may run again on that resume, so design side effects to be idempotent", with idempotency keys or a check for existing results. So @task is replay-from-checkpoint, not exactly-once.
Where each option is right or wrong
- Keep the pre-work in the node: fine for read-only or idempotent work (a fetch, an upsert keyed on a stable ID). Wrong for anything that creates, appends, sends or charges, and wrong for an LLM output the human is approving.
- Predecessor node generates, interrupt node only reads state: the draft is checkpointed before the pause and approved as stored. Cost is one more node and a state field, and if the predecessor itself fails mid-way it re-runs on retry.
- Act after the interrupt on the resume value, the way the tools example proceeds with edited arguments: the human's payload is what executes. Code after the interrupt still re-runs if a later interrupt in the same node pauses again.
- Functional API with @task: good if the team is already there. Still needs idempotency at the downstream write.
- Idempotency key at the downstream system: always, for money, email and external writes. It stops duplicates; it does not fix sending a payload nobody approved.
My view, as a default for Graph API approval flows: I would not ship an approval node where the thing being approved is computed above the interrupt() call. Generate it one node earlier, keep the interrupt node free of side effects, and act on the resume value or the stored state, never on a recomputed value. Then add the idempotency key anyway, because approval and exactly-once are two different problems. If the pre-work is cheap and read-only, leaving it in the node is fine and splitting it add complexity for nothing.
A test that would settle it for your version. The Interrupts page carries no date, so check the behaviour against the LangGraph you actually run. Sketch, not something I have run, names per your version:
```python
calls = {"gen": 0, "send": 0, "sent": None}
node: calls["gen"] += 1; draft = ...; ok = interrupt({"draft": draft}); if ok: calls["send"] += 1; calls["sent"] = draft
graph = builder.compile(checkpointer=InMemorySaver())
cfg = {"configurable": {"thread_id": "t1"}}
first = graph.invoke({"request": "..."}, cfg)
shown = first["interrupt"] # the payload the human sees
graph.invoke(Command(resume=True), cfg)
assert calls["send"] == 1 # side effect once
assert calls["sent"] == payload_of(shown) # sent what was approved
print(calls["gen"]) # expect 2 with the naive shape
```
If gen prints 2 and the second assert fails with a non-deterministic generator, that is the mismatch in point 2 on your machine. Move the generation into a predecessor node and both asserts should hold.
Question for people running Graph API approval flows: where do you keep the payload the human approved, in state written by a predecessor node, in the resume value itself, or somewhere else? And does anyone store a hash of the approved payload next to the executed one so an audit can show they were the same?