LangGraph Interrupt Patterns Beyond Human Approval
Ask most developers what interrupt() is for in LangGraph and you will get the same answer: human approval. The agent wants to send an email or delete a record, the graph pauses, a human clicks approve or reject, and execution continues. That is the demo everyone builds first, and it is also the least interesting thing the primitive can do. Underneath the approval workflow sits a general-purpose mechanism for suspending a graph at an arbitrary point, persisting its entire state to a checkpointer, and resuming it later with an injected value — seconds later or three weeks later, from the same process or a different one. Once you see interrupt() as a durable pause-and-resume primitive rather than an approval button, a whole family of langgraph interrupt patterns opens up: editing tool calls instead of just vetoing them, collecting missing parameters mid-flight, looping until a human provides valid input, treating the human as just another tool, and even parking a graph while an external system finishes a job that takes hours. This article walks through those patterns with working code, and closes with the one gotcha that bites almost everyone in production: node re-execution.
What interrupt() Actually Does Under the Hood
Before the patterns make sense, you need an accurate mental model of the mechanism, because interrupt() does not behave like a blocking input() call, even though it looks like one.
When a node calls interrupt(payload), LangGraph raises a special internal exception that halts execution of that node. The payload — any JSON-serializable value — is surfaced to the caller under the __interrupt__ key in the stream or invoke result. The graph's state at that moment is written to the checkpointer against the thread ID you passed in the config. Then the process is free to do anything else, including exit entirely.
To resume, you invoke the same graph with the same thread ID, passing Command(resume=value) instead of fresh input. LangGraph reloads the checkpoint, re-executes the interrupted node from its beginning, and this time the interrupt() call does not pause — it returns the resume value as an ordinary function return. Your node code reads it like a variable and carries on.
Three consequences follow directly from this design, and every pattern below leans on at least one of them:
- A checkpointer is mandatory. Without one there is nothing to persist and nothing to resume. Use
InMemorySaverfor development and a Postgres or SQLite checkpointer for anything real. - The pause is durable. Because state lives in the checkpointer, resumption does not need to happen in the same process, on the same machine, or in the same week. This is what makes webhook-style waits possible.
- The node re-runs from the top on resume. Code before the
interrupt()call executes twice. This is the source of the idempotency problems covered at the end.
Here is the minimal skeleton every pattern builds on:
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command
from typing import TypedDict
class State(TypedDict):
draft: str
final: str
def review_node(state: State):
feedback = interrupt({"draft": state["draft"], "action": "review"})
return {"final": feedback}
builder = StateGraph(State)
builder.add_node("review", review_node)
builder.add_edge(START, "review")
builder.add_edge("review", END)
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "thread-1"}}
result = graph.invoke({"draft": "Hello world"}, config)
# result["__interrupt__"] contains the payload; the graph is paused
resumed = graph.invoke(Command(resume="Hello, world!"), config)
# review_node re-runs; interrupt() returns "Hello, world!"Notice the shape: interrupt() sends a payload out and receives a value back. It is a bidirectional channel, not a gate. Approval workflows use only a fraction of that bandwidth. The rest of this article uses all of it.
Editing Tool Calls Instead of Approving Them
The classic approval pattern gives the human two buttons. But a binary veto throws away the human's most valuable contribution: their ability to fix the request. If the agent wants to run refund_customer(amount=4999, customer_id="C-1024") and the correct amount is 499, rejecting the call forces the agent to guess again. Letting the human edit the arguments gets it right in one round trip.
The pattern: surface the proposed tool call in the interrupt payload, and accept a response object whose type determines what happens next — approve as-is, approve with edits, or reject with a reason the model can learn from.
def tool_review_node(state: State):
tool_call = state["pending_tool_call"]
decision = interrupt({
"type": "tool_review",
"tool": tool_call["name"],
"args": tool_call["args"],
})
if decision["action"] == "approve":
return Command(goto="execute_tool")
if decision["action"] == "edit":
edited = {**tool_call, "args": decision["args"]}
return Command(
goto="execute_tool",
update={"pending_tool_call": edited},
)
# rejection with feedback goes back to the agent, not to END
return Command(
goto="agent",
update={"messages": [{
"role": "tool",
"tool_call_id": tool_call["id"],
"content": f"Call rejected by reviewer: {decision['reason']}",
}]},
)Two details matter here. First, the node returns a Command object combining goto (routing) with update (state mutation), so a single interrupt can steer the graph down three different paths depending on the human's answer. Second, the rejection branch does not just stop — it writes the reviewer's reason into the message history as a tool message, so the model sees why it was overruled and can propose something better. A rejection without feedback teaches the model nothing; a rejection with feedback is a correction signal.
In practice, the edit path handles the majority of interventions. Humans rarely want to block an action outright; they want to change a recipient, cap an amount, or fix a date. Designing the interrupt payload so your frontend can render an editable form of the arguments — rather than a raw JSON blob next to a reject button — is most of the work in this pattern.
Collecting Missing Input Mid-Graph
Agents frequently discover halfway through execution that they are missing something only the user can supply: an account number, a date range, a choice between two ambiguous interpretations. The naive fix is to front-load a giant intake form before the graph starts. The better fix is to let the graph run until it actually needs the value, then interrupt to ask for exactly that.
def gather_details(state: State):
required = ["shipping_address", "delivery_window"]
missing = [f for f in required if not state.get(f)]
collected = {}
for field in missing:
answer = interrupt({
"type": "input_request",
"field": field,
"prompt": f"Please provide your {field.replace('_', ' ')}",
})
collected[field] = answer
return collectedThis looks like a loop over multiple interrupts, and it is — LangGraph supports several interrupt() calls inside one node. On each resume, calls that already received a value return it immediately from the checkpoint, and execution proceeds to the first unanswered interrupt. Resume values are matched to interrupt calls by order within the node, which leads to a hard rule: never reorder, add, or remove interrupt calls between pause and resume based on non-deterministic logic. Compute missing from state, which is stable across re-execution, and you are safe. Compute it from time.time() or a random sample and you will pair the wrong answer with the wrong question.
The payoff of asking lazily is that you only ever ask for what the run actually needs. A returns-processing agent might need a photo for damaged items but not for wrong-size items; an interrupt-driven intake asks for the photo only on the damaged path. Users answer two questions instead of nine, and your form logic lives in the graph where the branching already is, instead of being duplicated in frontend conditionals.
Validation Loops That Re-Prompt Until Input Is Valid
Nothing guarantees that the value a human sends back is usable. Dates arrive in the wrong format, amounts arrive negative, emails arrive without an @. Because interrupt() returns a value inside ordinary Python, you can wrap it in an ordinary loop and simply refuse to proceed until the input passes validation.
def get_budget(state: State):
question = "What is your maximum budget in USD?"
while True:
answer = interrupt({"type": "question", "prompt": question})
try:
budget = float(answer)
if budget <= 0:
raise ValueError
break
except (TypeError, ValueError):
question = (
f"'{answer}' is not a valid amount. "
"Please enter a positive number, e.g. 2500."
)
return {"budget": budget}Each iteration of the loop is a fresh interrupt: the graph pauses, the client resumes with a value, the node re-runs, previously answered interrupts replay from the checkpoint, and the loop either breaks or issues a new interrupt with a sharper error message. From the user's perspective this is just a chat that politely re-asks. From the graph's perspective, invalid input never escapes the node, so every downstream node can trust that budget is a positive float and skip defensive checks.
The subtle design win is where the validation lives. If you validate in the frontend, every client — web, Slack bot, internal admin tool — must reimplement the rules and they will drift. If you validate in the node, the graph is the single source of truth and any client that can send a resume value gets correct behavior for free. Keep the error message inside the interrupt payload, as above, so the re-prompt explains what was wrong; a bare repeat of the original question is how you end up with users typing the same bad value three times and abandoning the flow.
The Human as a Tool
The patterns so far interrupt at points you chose while designing the graph. A more flexible arrangement lets the model decide when it needs a human, by exposing the human as a tool the agent can call like any other.
from langchain_core.tools import tool
@tool
def ask_human(question: str) -> str:
"""Ask the human user a clarifying question when you are missing
information or facing an ambiguous choice. Use sparingly."""
return interrupt({"type": "human_tool", "question": question})Bind ask_human alongside your real tools, and the agent will emit a tool call for it whenever its own judgment says clarification beats guessing. The tool executor runs the function, hits the interrupt(), and the whole graph parks until the user replies. The reply flows back as the tool result, landing in the message history exactly like a search result or an API response would, so the model needs no special handling to incorporate it.
This inverts the control relationship of every previous pattern. You are no longer predicting where ambiguity will occur; you are giving the model a budget to resolve ambiguity wherever it finds it. That flexibility cuts both ways. A model with an ask_human tool and a vague system prompt will interrogate the user about things it should infer. The docstring is your steering surface — "use sparingly", "only when the answer materially changes your action", "never ask for information already present in the conversation" — and it deserves the same iteration you give your main prompt. Some teams also cap invocations per run in the tool node and return "no human available, use your best judgment" past the limit, which keeps a chatty model from stalling a batch pipeline.
The human-as-tool pattern composes cleanly with tool editing from earlier: the same review UI that renders editable tool calls can render ask_human calls as chat messages. To the infrastructure, both are just interrupts with different payload types.
Waiting on External Systems and Webhooks
Here is the pattern that has nothing to do with humans at all. Because an interrupted graph is fully persisted, interrupt() doubles as a durable wait for any long-running external process: a video render, a CI pipeline, a compliance check at a partner API, a bank transfer that settles overnight. No polling loop burning a worker, no fragile asyncio.sleep chains — the graph simply is not running while it waits.
def start_export(state: State):
job = export_service.create_job(state["dataset_id"])
# Park the graph until the external system calls back
result = interrupt({
"type": "external_wait",
"job_id": job.id,
"resume_hint": "POST /webhooks/export-complete",
})
if result["status"] != "succeeded":
return {"error": result.get("reason", "export failed")}
return {"export_url": result["url"]}The other half lives in your web server. When the external system fires its completion webhook, the handler looks up which thread was waiting on that job ID and resumes it:
@app.post("/webhooks/export-complete")
async def export_complete(payload: ExportWebhook):
thread_id = jobs_table.thread_for(payload.job_id)
config = {"configurable": {"thread_id": thread_id}}
await graph.ainvoke(
Command(resume={
"status": payload.status,
"url": payload.url,
"reason": payload.error,
}),
config,
)
return {"ok": True}You need one piece of bookkeeping: a mapping from external job ID to thread ID, written when the job is created. Everything else is the standard interrupt lifecycle — the webhook handler is just a client sending a resume value, indistinguishable from a human clicking a button.
Note the re-execution trap lurking in this node: export_service.create_job runs before the interrupt, so it executes again on resume and would create a second job. The fix is to make the call idempotent — pass a deterministic idempotency key derived from the thread and dataset, or check state for an existing job ID before creating one. We will generalize this in a moment, but this pattern is where it hurts first, because the duplicated side effect costs real money or real compute.
Add a scheduled sweep that resumes threads whose jobs exceeded a timeout with {"status": "timeout"}, and you have a complete, restart-safe integration with any slow external system in about forty lines.
Reviewing and Rewriting Agent Output
Approval asks "may I?"; review asks "is this good, and if not, make it good." For content-producing agents — report writers, email drafters, SQL generators — the valuable human contribution is the edited artifact itself, and often you want the model to learn from the edit within the same run.
def draft_review(state: State):
decision = interrupt({
"type": "draft_review",
"draft": state["draft"],
"revision": state.get("revision", 0),
})
if decision["action"] == "accept":
return Command(goto="publish", update={"final": state["draft"]})
if decision["action"] == "rewrite":
# Human rewrote it directly; their version is final
return Command(goto="publish", update={"final": decision["text"]})
# "revise": send notes back to the writer node for another pass
return Command(goto="writer", update={
"feedback": decision["notes"],
"revision": state.get("revision", 0) + 1,
})The three-way split matters. Accept and rewrite both terminate the loop — the difference is whose text ships. Revise routes back to the writer node carrying the human's notes, and because the graph is a loop, the next draft comes back to the same review interrupt with revision incremented. Cap the revision count in the writer's inbound edge or in the node itself, or an indecisive reviewer can cycle forever.
A practical refinement: when the human chooses rewrite, store a compact diff of their edit in state and append it to the writer's prompt on future runs of the same thread. The model will not magically internalize style preferences, but even two or three concrete before/after examples in context measurably shift the next draft toward what the reviewer actually wants — which reduces how often they need to intervene at all. The interrupt is not just a quality gate; it is a data collection point for steering the model.
The Re-Execution Gotcha and How to Write Interrupt-Safe Nodes
Every pattern above depends on one behavior that surprises everyone the first time: on resume, the interrupted node re-executes from its first line. LangGraph replays answered interrupt() calls from the checkpoint, but everything else in the node runs again for real. The rules for staying safe are short:
- Put side effects after the interrupt whenever possible. Code after the last resumed interrupt runs exactly once per successful pass, so charging the card after approval is safe; charging before asking is a double charge.
- Make unavoidable pre-interrupt side effects idempotent. API calls should carry idempotency keys derived from the thread ID and step. Database writes should be upserts keyed on something stable.
- Keep pre-interrupt code deterministic. The number and order of interrupts in a node must be identical across executions, because resume values are matched by position. Derive branching from state, never from clocks, randomness, or external reads that can change between pause and resume.
- Move expensive non-idempotent work into its own node. Node boundaries are checkpoint boundaries: a completed node is never re-executed by a later interrupt. If a node does an expensive LLM call and then interrupts, split it — do the call in node A, interrupt in node B, and the call happens once no matter how many times B resumes.
Rule 4 is the workhorse. When a node grows a mix of computation, side effects, and an interrupt, the fix is almost never clever guarding inside the node — it is splitting the node. Small nodes are not just cleaner; in LangGraph they are the unit of exactly-once execution.
It is also worth knowing when not to use interrupt() at all. Static breakpoints — interrupt_before=["node"] at compile time — pause the graph without re-execution semantics and without a resume payload, which makes them the right tool for debugging and step-through inspection, and the wrong tool for the conversational patterns in this article. Dynamic interrupt() is for production control flow; static breakpoints are for development. Mixing up the two is a common source of confusion in older tutorials written before dynamic interrupts existed.
Where to Go From Here
The through-line of every pattern here is the same shift in perspective: interrupt() is not an approval button, it is a durable, bidirectional pause point with the full graph state saved behind it. Tool editing uses the return value to mutate a call. Input collection uses ordering guarantees to run multi-question intakes. Validation loops wrap it in plain Python control flow. Human-as-a-tool hands the pause decision to the model. Webhook waits remove the human entirely and let external systems drive resumption. And all of them live or die by how carefully you handle node re-execution.
If you want to build these patterns hands-on — with checkpointers beyond the in-memory saver, resume flows wired to a real frontend, and the debugging workflow for inspecting paused threads — our LangGraph Tutorial course on teachyou.ai walks through each one in a project you can deploy, from a first approval gate to a full webhook-driven pipeline. The interrupt primitive is small enough to learn in an afternoon and deep enough to carry most of the human-in-the-loop and long-running-workflow architecture your agents will ever need. Start with the pattern closest to your current pain point, split your nodes at the side effects, and let the checkpointer do the waiting.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.