Appearance
5.4 — Interrupts and Human-in-the-Loop
1. What is it?
interrupt() is LangGraph's mechanism for pausing a graph's execution mid-run, from inside a node, waiting for external input (most commonly, human approval) before continuing — and Command(resume=...) is how that paused execution is resumed with the human's response. This is the concrete, current implementation of the human-in-the-loop pattern that Parts 3.7, 3.9, and 4.6 have each referenced as a critical production safeguard for high-stakes agent/workflow actions.
2. Why does it exist?
Part 3.7 and Part 9.2 both argued that certain actions (destructive, high-cost, or high-stakes decisions) warrant a human checkpoint before execution — but implementing this naively (just... waiting, synchronously, inside a running process, for a human to click "approve") doesn't work in any real production system, since a human might take minutes, hours, or days to respond, and you can't hold a server process and its resources hostage for that entire span. interrupt() exists to solve this properly: it works together with checkpointing (Part 5.3) to actually free up the running process while genuinely preserving the paused state durably, so the wait can be arbitrarily long without consuming any live compute resources during that wait.
3. What problem does it solve?
It solves "how do I pause a multi-step workflow for human input, potentially for a very long time, without holding open a live process/connection for the entire wait, and without losing any of the workflow's accumulated progress" — directly building on Part 5.3's checkpointing to make this both efficient and safe.
4. How does it work internally?
interrupt() from inside a node
python
from langgraph.types import interrupt
async def approval_node(state: LoanReviewState) -> dict:
"""Pauses execution, requesting human approval before the loan proceeds."""
decision = interrupt({
"type": "approval_required",
"application_id": state["application_id"],
"recommendation": state["final_recommendation"],
})
return {"approved": decision["approved"]}When interrupt(payload) is called inside a node, LangGraph does something genuinely different from a normal blocking wait: it saves a checkpoint capturing exactly this paused point (via whatever checkpointer is configured, Part 5.3 — meaning this mechanism structurally requires a persistent checkpointer to be meaningful in production, exactly as Part 5.3 flagged) and then returns control back to the caller of .ainvoke(), surfacing the payload you passed to interrupt() as the result — the node function itself does not keep running or block anything; execution genuinely exits back out of the graph run, with its exact state safely persisted at the interrupt point.
Node calls interrupt(payload)
│
▼
Checkpoint saved (state as of right before this point, Part 5.3)
│
▼
.ainvoke() call returns, surfacing payload to your application code
│
▼
(your application code shows the payload to a human, e.g., via a UI,
and the process is now free to serve other requests entirely —
this could be minutes or days)
│
▼
Human responds; your code calls .ainvoke() again with Command(resume=response_data)
│
▼
Checkpoint loaded; the paused node resumes as if interrupt() had simply
returned response_data — execution continues from exactly that pointResuming with Command(resume=...)
python
from langgraph.types import Command
config = {"configurable": {"thread_id": "loan-application-4521"}}
result = await compiled.ainvoke(
Command(resume={"approved": True, "approver_id": "loan_officer_42"}),
config=config,
)Command(resume=...) tells LangGraph: load the checkpoint for this thread_id, and treat the value passed as the return value of the specific interrupt() call that paused it — the approval_node function above resumes exactly where it left off, with decision now bound to {"approved": True, "approver_id": "loan_officer_42"}, and continues executing the rest of the node (and then the rest of the graph) normally from there.
Why this genuinely differs from a naive "just wait" implementation
The critical architectural property: between the interrupt and the resume, no process, thread, or connection needs to be held open at all. Your application server can be completely restarted, redeployed, or handle thousands of unrelated requests during the wait — because the paused execution's entire state lives durably in the checkpoint store (Part 5.3), not in any live process's memory or an open connection. This is precisely why interrupt()'s practical usefulness is inseparable from having a durable checkpointer configured (PostgresSaver, Part 5.3) — using interrupt() with MemorySaver would mean the "paused" state is lost the instant that specific process restarts, defeating the entire purpose.
5. Simple mental model
interrupt() is like a form that gets mailed out for a required signature, not a phone call left on hold. A phone call on hold (a naive blocking wait) ties up a line for the entire wait, however long that takes. Mailing a form (interrupt + checkpoint) means the requester's desk (the server process) is immediately free to do other work; the entire case file (checkpointed state) sits safely in a filing cabinet (durable checkpoint store) until the signed form comes back (resume), at which point work picks up exactly where it left off — regardless of whether that took an hour or a month, and regardless of which specific desk (server instance) happens to process the returned form.
6. Real-world example
The loan-review workflow (Parts 5.1–5.3) reaches approval_node for a borderline application. interrupt() pauses execution and returns the recommendation payload to the calling application, which displays it in a loan officer's queue/dashboard. The loan officer reviews it two days later (Part 5.3's real-world example) and clicks "approve" in the UI, which triggers the application backend to call .ainvoke(Command(resume={"approved": True, ...}), config={"thread_id": "loan-application-4521"}) — at which point the graph resumes exactly at approval_node, completes the remaining steps (perhaps notifying the applicant), and finishes. At no point during those two days was any server process or resource dedicated to "waiting" for this specific application.
7. Architecture diagram
approval_node calls interrupt(payload)previous nodes already complete, each checkpointed (Part 5.3)
Checkpoint saved (paused).ainvoke() RETURNS, surfacing payload — process is now FREE
Human reviews payload, decidesvia your application's own UI — LangGraph has no opinion on it. Arbitrary time can pass here; zero resources held.
.ainvoke(Command(resume=...), config={thread_id})
approval_node resumes exactly at interrupt() pointgraph execution continues to completion
8. Production considerations
interrupt()requires a persistent checkpointer to be meaningful in production (Part 5.3) — this is not optional infrastructure for this feature specifically; usingMemorySaverwithinterrupt()in production is a guaranteed data-loss risk the moment the process restarts during a pending approval.- Build the human-facing side (a UI, a Slack notification, an email) explicitly — LangGraph's
interrupt()mechanism only handles the pause/resume machinery; surfacing thepayloadto an actual human and capturing their response is entirely your application's responsibility to build. - Design the interrupt
payloadto contain everything a human reviewer needs to make an informed decision, since it's the only information that crosses the pause boundary into whatever review interface you build — an underspecified payload forces the reviewer to go dig up context elsewhere, undermining the whole point of a smooth approval flow. - Set explicit expectations (and possibly a timeout/escalation policy) for how long a paused thread can realistically remain pending — an indefinitely-pending approval with no escalation path is a real operational risk (an application stuck forever because a specific approver went on vacation, for instance).
9. Common mistakes
- Using
interrupt()without a durable checkpointer, silently reintroducing the exact data-loss risk this whole mechanism exists to prevent. - Designing an interrupt payload that's too sparse for a human to make a confident decision from, causing reviewers to need to look elsewhere for context — defeating the UX benefit of a clean approval flow.
- Not building any timeout, reminder, or escalation mechanism for paused threads, leading to applications silently stuck in limbo indefinitely.
- Conflating "we added an interrupt" with "we've made this action safe" (Part 3.7's warning restated) — an interrupt only provides real safety if a human genuinely reviews the payload thoughtfully, not if approval becomes a reflexive rubber stamp.
10. Security considerations
- The resume call must be authorized — exactly Part 5.3's authorization gap, now specifically relevant to who is allowed to submit an approval/rejection for a given paused thread; verify the resuming caller is actually the intended, authorized approver (e.g., the specific loan officer assigned to this case), not just any authenticated user of the system.
- The interrupt payload itself may contain sensitive data (a loan applicant's financial details, in this chapter's running example) that's now surfaced to whatever review interface displays it — apply the same data-handling and access-control discipline (Part 9.6) to that review interface as to any other system touching this data.
11. Performance considerations
- The pause itself costs nothing in terms of compute (section 4's core architectural benefit) — the only "performance" consideration is the checkpoint write/read at the pause/resume boundary, which is the same cost as any other checkpoint operation (Part 5.3, section 11).
12. Cost considerations
- No LLM-token cost from the pause itself — but a long-pending approval delays whatever business outcome the workflow was driving toward, which is a real (non-monetary-to-LangGraph, but very real to the business) cost worth accounting for in Part 15's ROI/business-impact framing (a loan decision delayed by a slow approval process has a real business cost, even though it doesn't show up in an LLM API bill).
13. When to use it
Any workflow step where a human must review and approve/reject before a high-stakes, costly, or hard-to-reverse action proceeds (Part 9.2's excessive-agency mitigation, made concrete) — destructive tool calls, financial commitments, customer-facing communications above a certain risk threshold, or any decision your business/compliance requirements mandate human sign-off for.
14. When NOT to use it
Low-stakes, easily-reversible, or high-volume routine actions shouldn't be gated behind human approval — doing so both defeats the purpose of automation and, per section 9, risks approval fatigue turning the safeguard into a rubber stamp that provides false confidence rather than real safety. Reserve interrupt() for the genuinely high-stakes checkpoints Part 3.7/9.2 identify, not as a default safety blanket applied to every action.
15. Alternatives and trade-offs
| Approach | Good for | Weak point |
|---|---|---|
interrupt() + Command(resume=...) | Genuine, resource-free pausing for arbitrarily long human review, integrated with checkpointing | Requires a durable checkpointer; requires building your own human-facing UI |
| Synchronous blocking wait (anti-pattern) | Simplicity for a trivial demo | Ties up process/resources for the entire wait; completely impractical for real approval timescales |
| Fully automated, no human checkpoint | Fastest, no delay | Removes the safety checkpoint Part 9.2 argues is necessary for high-stakes actions |
16. Practical Python/code example
python
from langgraph.types import interrupt, Command
async def approval_node(state: LoanReviewState) -> dict:
"""Pauses for human approval before a loan recommendation is finalized."""
decision = interrupt({
"application_id": state["application_id"],
"recommendation": state["final_recommendation"],
"flagged_issues": state["flagged_issues"],
})
return {"approved": decision["approved"], "approver_id": decision["approver_id"]}
# Initial call — runs until it hits the interrupt
config = {"configurable": {"thread_id": "loan-application-4521"}}
result = await compiled.ainvoke(initial_state, config=config)
pending_payload = result["__interrupt__"] # verify exact current field name in docs
# ... time passes; a human reviews pending_payload via your application's UI ...
# Resume call
final_result = await compiled.ainvoke(
Command(resume={"approved": True, "approver_id": "loan_officer_42"}),
config=config,
)17. Production-quality example
An authorized-resume wrapper combining this chapter's mechanism with Part 5.3's authorization pattern and an explicit escalation timeout, addressing sections 8/9/10 together:
python
import logging
from datetime import datetime, timedelta, timezone
logger = logging.getLogger("hitl_approval")
class ApprovalWorkflow:
"""Manages human-in-the-loop approval for a paused graph thread, with
authorization checks and staleness detection for unresolved approvals."""
def __init__(self, compiled_graph, approval_record_store, max_pending: timedelta):
"""
Args:
compiled_graph: A compiled LangGraph graph using a durable checkpointer.
approval_record_store: Store tracking who is authorized to approve which
thread_id, and when each interrupt was first raised.
max_pending (timedelta): How long a pending approval can remain before
being flagged for escalation.
"""
self._graph = compiled_graph
self._store = approval_record_store
self._max_pending = max_pending
async def resume_with_approval(
self, thread_id: str, approver_id: str, approved: bool
) -> dict:
"""
Resumes a paused thread, verifying the approver is authorized and logging
the decision for audit purposes.
Args:
thread_id (str): The paused thread to resume.
approver_id (str): The user submitting the approval decision.
approved (bool): Whether the action is approved.
Returns:
dict: The graph's result after resuming.
Raises:
PermissionError: If approver_id isn't authorized for this thread.
"""
record = await self._store.get_pending_approval(thread_id)
if record is None or approver_id not in record.authorized_approver_ids:
logger.warning(
"approver=%s not authorized for thread=%s", approver_id, thread_id
)
raise PermissionError("not authorized to approve this thread")
logger.info(
"thread=%s approved=%s by approver=%s (pending for %s)",
thread_id, approved, approver_id, datetime.now(timezone.utc) - record.raised_at,
)
from langgraph.types import Command
config = {"configurable": {"thread_id": thread_id}}
result = await self._graph.ainvoke(
Command(resume={"approved": approved, "approver_id": approver_id}), config=config
)
await self._store.mark_resolved(thread_id)
return result
async def find_stale_pending_approvals(self) -> list[str]:
"""
Returns thread_ids of approvals pending longer than max_pending, for
escalation (e.g., notifying a backup approver or an operations team).
Returns:
list[str]: thread_ids requiring escalation.
"""
cutoff = datetime.now(timezone.utc) - self._max_pending
return await self._store.find_pending_raised_before(cutoff)The find_stale_pending_approvals method directly implements the section 8 recommendation for an explicit escalation policy — without it, a paused thread with no active approver attention could remain pending indefinitely with no one aware.
18. Short exercise
A customer wants human approval before any AI-drafted email is sent to a client, but complains that requiring approval for every single email is slowing their team down too much. Using this chapter's section 14 guidance, propose a more targeted approval-gating policy (what specific criteria would trigger the interrupt versus letting an email send automatically) that preserves genuine safety for the highest-risk cases without applying blanket friction to every action.
19. Interview questions
- Explain why
interrupt()combined with a durable checkpointer avoids the resource cost of a naive blocking wait for human approval. - What specifically goes wrong if you use
interrupt()withMemorySaverinstead ofPostgresSaver? - Why is verifying the resuming caller's authorization a separate concern from LangGraph's
Command(resume=...)mechanism itself?
20. FDE/customer scenario
Customer: "We want a human to approve every action our AI agent takes before it happens, just to be safe."
This is exactly the over-application risk from section 14: blanket approval requirements typically produce reviewer fatigue, turning a genuine safety mechanism into a low-attention rubber stamp for the majority of low-stakes actions, while adding real friction and delay to the whole system. The FDE-correct response walks through Part 9.2's excessive-agency risk assessment with the customer to identify which specific actions are genuinely high-stakes (and deserve interrupt()) versus which are safe to run automatically — producing a system that's both safer for what actually matters and meaningfully faster for everything else, rather than uniformly cautious and uniformly slow.
Key takeaways
interrupt()pauses execution by checkpointing state and returning control to the caller — genuinely freeing the process, not blocking it, which is what makes arbitrarily long human-review waits practical.interrupt()'s usefulness is inseparable from a durable checkpointer (Part 5.3) — using it withMemorySaverdefeats its entire purpose.- Human-in-the-loop should be applied deliberately to genuinely high-stakes actions, not blanket-applied everywhere, or it degrades into ineffective rubber-stamping.
Things you should be able to explain
- Why
interrupt()+ checkpointing avoids tying up process resources during a long human-review wait. - The authorization gap between LangGraph's resume mechanism and your application's responsibility to verify who can approve what.
Things you should be able to build
- An authorized approval-resume workflow with staleness detection and escalation for unresolved approvals.
Common mistakes
- Using
interrupt()without a durable checkpointer. - No authorization check on who can submit a resume/approval.
- Blanket-applying human approval to every action, causing reviewer fatigue.
Recommended next chapter
05-streaming.md