A graph interrupt looks like a pause button. In production, it is closer to a public API.
That distinction matters because agent graphs do not pause in a clean little world where everything around them freezes. The user interface may close. The worker may restart. A policy service may change its answer. The operator who approved the next step may be different from the person who started the run. The graph may be replayed from a checkpoint and execute part of the node again.
The engineering rule is simple:
If a graph can pause, the resume value is a contract. Version it, validate it, and attach it to the exact checkpoint it is allowed to release.
This article is about a narrow but expensive failure mode in graph-based agent systems: treating human-in-the-loop approval as a transient UI event instead of a durable resume protocol.
The runtime fact most teams miss
LangGraph interrupts pause graph execution, save graph state through the persistence layer, and later resume when the application invokes the graph with a Command carrying the resume value. The docs also make two details explicit that should shape the production design:
- The
thread_idis the persistent cursor that tells the checkpointer which state to load. - When an interrupted node resumes, the node restarts from the beginning, so code before the
interrupt()call runs again.
That means an interrupt is not just a call to confirm() with nicer ergonomics. It is a boundary between durable state and external authority.
LangGraph time travel sharpens the point. Replay from a checkpoint does not merely read cached results. Nodes after the checkpoint re-execute, including LLM calls, API requests, and interrupts. Persistence gives you the graph snapshot. It does not automatically tell you whether a human approval is still valid, whether the approval belonged to the right state, or whether the operation behind that approval already happened.
A production graph needs a resume contract.
Pause is a control-plane transition
In a graph, nodes do work and edges decide what runs next. Some nodes are pure transformations. Some call models or APIs. Some cross an authority boundary: deploy this change, email this customer, run this database migration, approve this refund, release this tool call.
Interrupts are often placed exactly at that authority boundary. The system stops and asks a person, policy service, or another process to decide whether the graph may continue.
The mistake is to model that response as a boolean.
approved = interrupt("Approve deployment?")
if approved:
deploy()
That code captures the demo. It does not capture the operating contract. A boolean cannot answer the questions that matter during replay, audit, retries, or incident review:
- Which checkpoint was approved?
- Which actor approved it?
- What action scope was approved?
- Which proposed tool call or diff did the actor see?
- Has the approval expired?
- Was this resume value produced by a compatible schema version?
- Is this a new decision, a duplicate resume, or a replay of a prior approval?
A senior engineer should hear an interrupt and think: state transition, schema, idempotency, audit, expiry, and replay semantics.
A resume contract has more than a decision
A useful resume payload should be boring. It should be JSON-serializable, explicit, and small enough to review in logs without exposing secrets.
{
"schema_version": "approval.v1",
"thread_id": "run_01J9R4J6K2",
"checkpoint_id": "ckpt_00042",
"interrupt_id": "deploy_gate",
"actor": {
"type": "human",
"id": "ops_user_17"
},
"decision": "approved",
"scope": {
"action": "deploy",
"environment": "staging",
"service": "retrieval-api"
},
"evidence_hash": "sha256:9b1c0000",
"idempotency_key": "resume:run_01J9R4J6K2:ckpt_00042:deploy_gate",
"expires_at": "2026-09-04T15:00:00Z"
}
The important fields are not bureaucratic. Each one closes a real production gap.
| Field | Production job |
|---|---|
schema_version |
Lets old approvals fail closed when the graph contract changes. |
thread_id |
Binds the resume to the durable cursor used by the checkpointer. |
checkpoint_id |
Prevents a stale approval from releasing a different graph state. |
interrupt_id |
Distinguishes multiple pauses in one run. |
actor |
Supports audit, authorization, and separation of duties. |
scope |
Defines exactly what was approved. |
evidence_hash |
Binds the approval to the prompt, diff, tool call, or plan the actor saw. |
idempotency_key |
Makes duplicate resume attempts observable and safe. |
expires_at |
Prevents old approval from becoming ambient authority. |
The goal is not to make every approval heavyweight. The goal is to stop pretending that true is enough information to safely continue a durable graph.
Reference architecture
flowchart LR
A["Graph node"] --> B["Interrupt"]
B --> C["Checkpoint store"]
B --> D["Approval UI or policy service"]
D --> E["Resume contract"]
E --> F["Contract validator"]
F --> G{"Valid for checkpoint?"}
G -->|"yes"| H["Command(resume=contract)"]
G -->|"no"| I["Fail closed"]
H --> J["Node restarts"]
J --> K["Effect ledger"]
The validator is the key component. It should run before the graph receives the resume value, and again inside the interrupted node if the node performs sensitive work. That second check matters because the node restarts from the beginning when it resumes. Any computation before the interrupt may run again and may observe new time, new policy, or new external state.
Minimal implementation pattern
The following sketch uses plain Python validation so the contract is visible. In a production codebase, you would likely use Pydantic or another schema library, and you would store approval receipts in the same operational database that owns your run ledger.
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Literal, TypedDict
from langgraph.types import Command, interrupt
class ApprovalScope(TypedDict):
action: str
environment: str
service: str
class ApprovalContract(TypedDict):
schema_version: Literal["approval.v1"]
thread_id: str
checkpoint_id: str
interrupt_id: str
actor_id: str
decision: Literal["approved", "rejected"]
scope: ApprovalScope
evidence_hash: str
idempotency_key: str
expires_at: str
@dataclass(frozen=True)
class ExpectedApproval:
thread_id: str
checkpoint_id: str
interrupt_id: str
evidence_hash: str
scope: ApprovalScope
def validate_resume(contract: ApprovalContract, expected: ExpectedApproval) -> None:
if contract["schema_version"] != "approval.v1":
raise ValueError("unsupported approval contract")
if contract["thread_id"] != expected.thread_id:
raise ValueError("approval belongs to a different thread")
if contract["checkpoint_id"] != expected.checkpoint_id:
raise ValueError("approval belongs to a different checkpoint")
if contract["interrupt_id"] != expected.interrupt_id:
raise ValueError("approval belongs to a different interrupt")
if contract["evidence_hash"] != expected.evidence_hash:
raise ValueError("approval evidence changed")
if contract["scope"] != expected.scope:
raise ValueError("approval scope changed")
expires_at = datetime.fromisoformat(contract["expires_at"].replace("Z", "+00:00"))
if expires_at < datetime.now(timezone.utc):
raise ValueError("approval expired")
def approval_node(state: dict) -> dict:
expected = ExpectedApproval(
thread_id=state["thread_id"],
checkpoint_id=state["checkpoint_id"],
interrupt_id="deploy_gate",
evidence_hash=state["proposal_hash"],
scope={"action": "deploy", "environment": "staging", "service": "retrieval-api"},
)
contract = interrupt({
"kind": "approval_required",
"interrupt_id": expected.interrupt_id,
"scope": expected.scope,
"evidence_hash": expected.evidence_hash,
})
validate_resume(contract, expected)
return {"approval": contract}
The approval UI should not invent the allowed scope on its own. It should render the interrupt payload, collect a decision, and return a contract that refers back to the checkpoint and evidence it reviewed.
Treat resumed work as at-least-once
The resume contract tells the graph it may continue. It does not make the next side effect exactly once.
Because an interrupted node can restart, anything before the interrupt must be safe to run more than once. Anything after the interrupt that touches the outside world still needs an idempotency key and a receipt. This is where the resume contract and the effect ledger meet.
A practical rule:
The resume contract authorizes the action. The effect ledger records whether the action happened. Do not make one do the other's job.
For example, a deployment node can use the approval contract's idempotency_key as part of a deployment request key, but it should still write a separate deployment receipt:
def deploy_after_approval(state: dict, deployment_client, effect_ledger) -> dict:
approval = state["approval"]
effect_key = f"deploy:{approval['idempotency_key']}"
prior = effect_ledger.get(effect_key)
if prior:
return {"deployment": prior, "effect_status": "already_done"}
result = deployment_client.deploy(
service=approval["scope"]["service"],
environment=approval["scope"]["environment"],
idempotency_key=effect_key,
)
receipt = {"deployment_id": result.id, "status": result.status}
effect_ledger.put(effect_key, receipt)
return {"deployment": receipt, "effect_status": "created"}
This separation is what keeps replay honest. Approval is not proof of execution. Execution is not proof of approval. Both need receipts.
Failure modes to design for
Stale approval. An operator approves checkpoint 42, but the graph is now at checkpoint 47. Fail closed. Ask for a new approval against the new evidence.
Duplicate resume. A user double-clicks approve, a webhook retries, or a worker crashes after sending Command(resume=...). The idempotency key should make the second resume observable as a duplicate, not a second authorization.
Schema drift. The graph now expects approval.v2, but an old UI still returns approval.v1. Reject at the validator, not later in the node after partial work has happened.
Evidence mismatch. The approval screen showed one tool call, but the resumed graph is about to execute another. Compare an evidence hash or proposal id before allowing the action.
Expired authority. Long-lived graph threads are useful, but old approvals should not live forever. Expiry turns human approval into bounded authority instead of a permanent capability.
Replay after policy change. Replay may re-execute nodes after a checkpoint. If policy rules changed after the approval, decide deliberately whether the old approval remains valid. For high-risk actions, store the policy version or policy decision id in the contract.
How to review an interrupt design
Before shipping a graph with human-in-the-loop pauses, ask these questions:
- Can every interrupt be named with a stable
interrupt_id? - Is the resume payload versioned and JSON-serializable?
- Does the approval bind to
thread_id, checkpoint, scope, and reviewed evidence? - Does the graph fail closed when any of those values mismatch?
- Are duplicate resumes idempotent and visible in traces?
- Is work before the interrupt safe to rerun?
- Are external effects after the interrupt protected by an effect ledger?
- Can an incident reviewer explain who approved what, for which graph state, and what happened after resume?
If the answer is no, the interrupt is still a demo pause, not a production control point.
The decision rule
Graph persistence gives you continuity. Interrupts give you a way to involve humans or external systems. Replay gives you a way to recover and explore alternative paths.
Those features only become safe together when the authority crossing is explicit.
Do not design approval around a button. Design it around a resume contract:
- the graph checkpoint that is being released,
- the actor or policy service that released it,
- the exact scope and evidence approved,
- the schema version that makes the payload meaningful,
- the idempotency key and receipts that make replay safe.
That is the difference between a graph that can pause and a graph that can be operated.