Interrupts that land mid-tool-call
Reviewing a tool call before it fires is the highest-stakes interrupt there is, and the node re-execution rule makes naive placement of the side effect actively dangerous.
Every interrupt so far has paused a node that proposes something. The broker looks at an extracted number and says yes, no, or here is the right one. Nothing has happened to the outside world yet, so a pause costs nothing and a resume costs nothing.
Now put the interrupt in front of an action. The agent is about to file the corrected document with a downstream system, email a counterparty, or move money. This is the interrupt worth building, because a review that cannot stop an action is decoration. It is also the interrupt with a documented trap under it, and the trap is not in the UI. It is in where the node author put one line.
The rule
This is the sentence the whole module has been building towards, quoted from the interrupts page:
“the runtime restarts the entire node from the beginning—it does not resume from the exact line whereinterruptwas called. This means any code that ran before theinterruptwill execute again.” (LangChain, Interrupts)
A node is the unit of resumption, not a line. The runtime replays the node body from the top, and this time interrupt() returns the resume value instead of throwing. Everything above that call runs a second time. If a superstep contains several interrupts and one of them gets answered, other nodes in the same superstep can be re-entered too.
Read that as a plain fact about control flow rather than a bug. It is what makes the node body readable straight down. The cost is that the top of the node has to be safe to run more than once.
The same node, wrong and right
// WRONG: side effect before interrupt re-runs on every resume
async function sendEmailNode(state) {
await actuallySendEmail(state.to, state.body); // re-executes on resume!
const approved = interrupt({ action: "send_email", ... });
}
// RIGHT: side effect after interrupt resolves
async function sendEmailNode(state) {
const decision = interrupt({ action: "send_email", to: state.to,
subject: state.subject, body: state.body,
message: "Approve sending this email?" });
if (decision.verdict === "approve") {
await actuallySendEmail(state.to, state.body); // runs once, after resume
}
}The wrong version is not obviously wrong when you read it. It sends the email and then asks for approval, which looks like a sequencing mistake anyone would catch — until you imagine how it got written. Someone added an approval step to a node that already worked, and put the new call at the end where new code goes. The email was already being sent before the review existed. Now it is sent once per resume, and the approval it collects governs nothing.
The docs give three mitigations, and they are worth knowing as three rather than one:
- Keep pre-interrupt work idempotent. Fine for reads, lookups and pure computation. Not a licence for writes.
- Put side effects after the interrupt. The default answer, and the one that produces the readable node above.
- Isolate side effects in their own node. The strongest version: the reviewing node returns a decision into state and the acting node runs afterwards, so the acting node contains no interrupt at all and cannot be re-entered by one.
Say plainly what the framework does about this: nothing. There is no check, no warning, and no runtime error for a write sitting above an interrupt(). The docs describe the rule and the mitigations, and placement is entirely the node author’s responsibility. That is why this is a review skill.
The version that bites a good engineer
Duplicate emails are easy to see. Here is the failure that survives code review, and it is this course’s own conclusion drawn from two documented facts rather than a warning printed on any page.
Fact one: code above the interrupt() call runs again on resume. Fact two: re-executed nodes genuinely re-run their work, and “LLM calls, API requests, and interrupts fire again and may return different results” — LangChain’s own wording, on the time-travel page. Put the two together for a node that drafts and then reviews:
async function draftAndSendNode(state) {
const draft = await model.invoke(draftPrompt(state)); // runs twice
const decision = interrupt({ action: "send_email", body: draft.text });
if (decision.verdict === "approve") {
await actuallySendEmail(state.to, draft.text); // which draft?
}
}There is no duplicate side effect here. The send is after the interrupt, the approval is checked, and the node looks correct. But the draft the broker read came from the first execution, and the draft that gets sent came from the second. A model call is not required to return the same text twice. The approval was collected against one artefact and applied to another.
The fix is the mechanism from the decision-surface lesson: the resume value can override inputs before executing. Send the reviewed artefact back with the verdict and act on that, not on whatever the node recomputed.
const decision = interrupt({ action: "send_email", body: draft.text });
// resume payload: { verdict: "approve", body: "…the exact text shown…" }
if (decision.verdict === "approve") {
await actuallySendEmail(state.to, decision.body);
}Where people get burned
Two constructions the docs call out specifically. Do not build a while (true) validation loop around interrupt() — the page warns it causes exponential re-execution of any code inside the loop body, and “keep asking until the value is valid” is the obvious thing to write after designing an edit verdict. Put the re-ask in its own node instead. And do not wrap the call in a bare try/catch, which swallows the GraphInterrupt the pause is made of.
What this changes about the screen
Three consequences that are yours rather than the node author’s.
The review is a preview, not a receipt. The screen is showing something that has not happened. Every word on it should be in the future tense, and the primary button names the action rather than agreeing with a statement. “Send this email” rather than “OK.” This sounds like copywriting and it is the difference between a person understanding they are the last check and a person acknowledging a notification.
The payload has to be exact. If the reviewer approves a summarised version of an action and the node executes the real one, the gate is theatre. The thing on screen and the thing that runs must be the same object, which is an argument for putting the executable payload in the interrupt value rather than a description of it.
Resume is not guaranteed to be a single click. A node isolated for safety may split one human moment across two supersteps, and a run can carry more than one pending interrupt at once, since pending interrupts surface as an array. A surface that assumes exactly one decision per pause will render the second one nowhere.
Retrieval check
You are reviewing an inherited node. It fetches a customer record, calls a model to draft a refund justification, interrupts for approval, then posts the refund. Nothing writes before the interrupt. What do you flag?
Check your answer
The fetch is a read and re-running it is harmless, though worth checking that it cannot return something different in a way that changes the screen. The refund post is after the interrupt, so it fires once. So far so good.
The model call is the problem. It runs again on resume and can produce a different justification, so the text the approver read is not necessarily the text attached to the refund. Flag it and offer two fixes: carry the approved justification in the resume payload and post that, or split the node so drafting finishes and lands in state before a separate review-and-post node runs.
The second question to ask is whether the refund post is idempotent on its own, because a node containing a side effect can be re-entered by any interrupt in its superstep, not only by this one. If the answer is no, that side effect wants its own node regardless.
Check your recall
Answer from memory — no scrolling back.
Hands on
Place the side effect, and close out the artifact’s interrupt section
Done when: ARTIFACT.md’s module 2 section shows the review node written twice — the naive placement and the safe one — names which of the three documented mitigations the safe version uses, and states in one sentence what guarantees the artefact a broker approved is the artefact that runs.
- Take the review node from this module’s earlier hands-on work and write it out as code, including whatever the parser actually does after approval. If the honest downstream step is “write the corrected fields somewhere,” that counts as a side effect and it belongs in this exercise.
- Write the naive version first, deliberately: the way it would look if somebody added the review to an existing working node by appending to it. Mark every line above the
interrupt()call that touches the outside world. - Rewrite it safely. Pick one of the three mitigations by name and say why that one. If any pre-interrupt line is a model call or anything else nondeterministic, isolation is usually the answer rather than reordering.
- Write the one sentence that guarantees approval and execution refer to the same artefact. It will either be “the approved value comes back in the resume payload and the node acts on that” or “the artefact is finalised in an earlier node and read from state,” and you should be able to say which.
- Update the review screen’s copy in your notes to match. Future tense, the button names the action, and the exact payload is visible rather than summarised. Note anything the screen currently shows that is not in the interrupt payload — that is a fetch you owe or a field you are missing.
- Fill in the Surprises list for module two. The re-execution rule catches most people; write down which version of it caught you, because that is the story that makes the interview answer concrete rather than recited.
- Bring both versions of the node into the chat. I will look for a nondeterministic line still sitting above the interrupt, and for a screen that describes an action rather than showing the payload that will run.
What this does not cover
This module treated the checkpoint as a place a paused run waits. It never read one. Threads as the unit a user calls “my run,” the snapshot fields that tell you what is still owed, and a URL scheme built on thread_id are the checkpointers-and-threads lesson, and reconstructing a half-finished run for someone returning the next morning is the lesson after it.
Re-running a node came up here only as a hazard. It is also a feature: replaying from a past checkpoint and forking a run are deliberate operations with their own API and their own UI problem, including the detail that interrupts fire again during time travel. That is the time-travel lesson.
And this lesson argued that a human should be able to deny a tool call because it is dangerous not to. There is a stronger version of that argument available: the protocol most portfolio companies will expose tools through writes the requirement down in normative language, which turns a design opinion into a citation. The MCP lesson in the last module of this course is where that lands.
Read this next — primary source
InterruptsLangChain — docs.langchain.com, JavaScript docs, fetched 2026-09-05. Vendor documenting its own product
Read this page a third time, for its last section. The node re-execution rule and the pitfalls that follow it are the part of the page that changes code rather than vocabulary, and they are easy to skim past on a first read because everything before them is about the happy path. The page states the rule, gives three mitigations, and warns about two specific constructions — a validation loop around interrupt, and a try/catch that swallows it. It does not tell you what to do about a model call sitting before the interrupt, which is where this lesson goes beyond it.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.