LangGraph 1.2.20 Deep Dive: Safer Human-in-the-Loop Agents with Strict Resume
Updated November 7, 2026 · 14-minute read
An agent pauses and asks a reviewer to approve a deployment. The reviewer clicks Approve. A slow browser retries the request, or a second tab submits an old approval. Meanwhile, the graph has already moved on and is waiting for a different answer. If your application cannot distinguish the old response from the current question, a human-in-the-loop checkpoint becomes a correctness and security problem.
LangGraph 1.2.20 addresses an important part of this problem: every interrupt() call in a task can now have its own ID, and an opt-in strict-resume mode can reject answers that do not match interrupts that are currently waiting. The Python SDK 0.4.14 exposes a strict_resume option for server-backed runs. This guide explains what changed, how the pieces fit together, and how to build an approval flow that does not treat a stale click as a fresh authorization.
Who should read this?
- Python developers building stateful agents with LangGraph.
- Teams that pause agents for human review before deployments, database changes, refunds, emails, or tool execution.
- Developers using the LangGraph API server and Python SDK.
- Engineers migrating older interrupt flows or debugging repeated approvals, retries, and multi-tab behavior.
What changed in November 2026?
The LangGraph 1.2.20 release was published on November 5, 2026. Its release notes list two relevant changes: distinct IDs for successive interrupt() calls within a task, and an opt-in strict-resume mode that refuses stale interrupt answers. The underlying changes were merged in pull requests #9589 and #9574 on November 4.
On November 6, the Python SDK 0.4.14 release added the strict_resume keyword to runs.create, runs.stream, and runs.wait. The SDK change is useful when your graph runs behind a LangGraph API server rather than directly in the same Python process.
- Per-interrupt IDs: repeated interrupt calls in one task no longer all share the same ID. The ID accounts for how many answers that task has already received.
- Strict resume: when enabled, a resume map containing an unknown or already-consumed interrupt ID is rejected instead of silently being ignored. An empty map and an ambiguous single value when multiple interrupts are pending are also rejected.
- Retry correction: a node retried from its first line reuses the answers already associated with that task instead of accidentally advancing to a new interrupt.
- SDK support: the Python SDK can request strict resume on server-backed thread runs with
strict_resume=True.
Important: strict resume is opt-in, not a substitute for authorization, and not every older server understands the SDK flag. The SDK documentation says the feature takes effect on servers whose /info response advertises strict_resume; a server that does not support it may ignore the field. Upgrade and verify both sides before relying on it.
How interrupts, IDs, and checkpoints fit together
An interrupt is a pause point inside a graph node. The node calls interrupt(payload), LangGraph saves the graph state through its checkpointer, and execution waits until a caller resumes the same thread with Command(resume=...). The resume value becomes the return value of that interrupt() call.
- Node execution: a node runs and reaches an
interrupt()call. - Checkpoint: LangGraph persists the thread state and surfaces the interrupt payload and ID to the caller.
- Human review: your UI displays the payload and records the response against that exact pending interrupt ID.
- Resume: the backend submits a
Commandusing the same thread and the ID/value mapping. - Continuation: the node starts again from its beginning. Previously answered interrupt calls in that task receive their saved answers; execution continues until the next interrupt or completion.
The last point is easy to miss. A node does not continue from the Python line after interrupt() as if it were a normal suspended coroutine. The node is re-entered from its first line. Keep code before an interrupt deterministic and free of non-idempotent side effects.
Why per-call IDs matter
Before the change, repeated interrupts in one task shared an ID derived from the task namespace. That made a stale response difficult to distinguish from the answer to the task's next question. In 1.2.20, an interrupt ID also incorporates the number of answers already recorded for that task. The first interrupt keeps the original namespace-based ID; later interrupts receive distinct IDs. The implementation uses the answer count rather than relying only on call order because parallel interrupt calls can be raised in different orders.
The change also preserves compatibility for IDs saved by older versions. The implementation can fall back to the first ID when a resume map has no entry for the task's next interrupt. That compatibility behavior is not a reason to reuse old IDs in new clients: always read the current pending interrupt IDs from the latest state or stream event.
What strict resume rejects
| Resume input | Strict-mode result |
|---|---|
| A map containing an ID that is not currently pending | UnknownInterruptError with reason not_pending |
| An empty resume map | UnknownInterruptError with reason empty_map |
| One scalar answer while several interrupts are pending | UnknownInterruptError with reason ambiguous |
| A map containing the IDs of current pending interrupts | Accepted for normal processing |
The error exposes the rejected IDs and the IDs still pending. The check is performed before graph writes are applied, so a rejected answer does not consume the waiting interrupts. A single value with no pending dynamic interrupt can still be valid when resuming a static interrupt_before or interrupt_after breakpoint.
Prerequisites
- Python environment compatible with the pinned LangGraph packages.
- LangGraph 1.2.20 for the per-interrupt ID behavior.
- A checkpointer. The example uses SQLite for a local, single-process demonstration.
- For the server-backed section, a LangGraph API server that advertises strict-resume support and
langgraph-sdk0.4.14. - A reviewer-facing backend that authenticates users and authorizes each approval independently of the graph payload.
1. Create a pinned Python environment
Use a virtual environment so the example does not change packages used by other projects:
python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
pip install \
"langgraph==1.2.20" \
"langgraph-checkpoint-sqlite==3.1.3" \
"langgraph-sdk==0.4.14" \
"pydantic>=2,<3"
On Windows PowerShell, activate the environment with .venv\Scripts\Activate.ps1. Confirm the installed versions:
python -c "from importlib.metadata import version; print('langgraph', version('langgraph')); print('langgraph-sdk', version('langgraph-sdk'))"
Expected result: the output should show langgraph 1.2.20 and langgraph-sdk 0.4.14. If dependency resolution selects another version, inspect the lock file before continuing.
2. Build a two-stage deployment approval graph
This example asks for a deployment decision and, if approved, asks for a reviewer note. Both questions occur in the same node, which makes it a useful way to see why each interrupt needs its own ID. The graph records the decision; it deliberately does not deploy anything.
from typing import Literal, TypedDict
from pydantic import BaseModel
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
class DeployState(TypedDict):
change_id: str
summary: str
diff_sha256: str
status: str
review_note: str
class Approval(BaseModel):
decision: Literal["approve", "reject"]
reason: str = ""
class ReviewerNote(BaseModel):
note: str
def review_deployment(state: DeployState):
decision = interrupt(
{
"kind": "deployment_approval",
"change_id": state["change_id"],
"summary": state["summary"],
"diff_sha256": state["diff_sha256"],
"question": "Approve this exact deployment?",
},
response_schema=Approval,
)
if decision.decision == "reject":
return {
"status": "rejected",
"review_note": decision.reason,
}
note = interrupt(
{
"kind": "reviewer_note",
"change_id": state["change_id"],
"question": "Add a short note for the audit record.",
},
response_schema=ReviewerNote,
)
return {
"status": "approved",
"review_note": note.note,
}
builder = StateGraph(DeployState)
builder.add_node("review_deployment", review_deployment)
builder.add_edge(START, "review_deployment")
builder.add_edge("review_deployment", END)
The Pydantic response schemas validate the shape of the values returned to the node. They do not prove that the person submitting the value is an authorized reviewer. Your application must perform that check before sending a resume command.
3. Add a persistent local checkpointer
For a laptop demo, SQLite keeps state across process restarts. Append this to the same file and run it as a script:
from langgraph.checkpoint.sqlite import SqliteSaver
THREAD_ID = "deploy-change-2026-11-07-001"
initial_state = {
"change_id": "CHANGE-1042",
"summary": "Deploy API release 2.8.1",
"diff_sha256": "replace-with-the-real-reviewed-diff-hash",
"status": "pending",
"review_note": "",
}
with SqliteSaver.from_conn_string("approval-checkpoints.sqlite") as checkpointer:
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": THREAD_ID}}
paused = graph.invoke(initial_state, config)
first = paused["__interrupt__"][0]
print("First pending ID:", first.id)
print("First request:", first.value)
# In a real application, return this payload to the authenticated reviewer.
after_approval = graph.invoke(
Command(
resume={
first.id: {
"decision": "approve",
"reason": "Reviewed the release diff and checks.",
}
}
),
config,
)
second = after_approval["__interrupt__"][0]
print("Second pending ID:", second.id)
print("Second request:", second.value)
final_state = graph.invoke(
Command(
resume={
second.id: {
"note": "Approved after staging verification."
}
}
),
config,
)
print("Final state:", final_state)
Expected flow: the first invocation pauses for deployment approval. The first resume supplies the answer to that interrupt; because the node is replayed, it passes the first call using the stored answer and pauses at the reviewer-note interrupt. That second interrupt has its own ID. The second resume completes the node and returns a final state with status set to approved.
Do not hard-code a thread ID shared by unrelated users in a real application. Use a unique, server-generated thread ID and bind it to the authenticated owner and tenant.
4. Always resume using the current interrupt ID
For a local graph, the __interrupt__ tuple returned by invoke() contains interrupt objects with id and value. You can also inspect the current snapshot:
snapshot = graph.get_state(config)
for task in snapshot.tasks:
for pending in task.interrupts:
print({
"interrupt_id": pending.id,
"payload": pending.value,
})
For several interrupts that are pending at the same time—for example, parallel tasks—build a mapping from every current interrupt ID to its corresponding answer:
pending = [
item
for task in graph.get_state(config).tasks
for item in task.interrupts
]
resume_map = {
item.id: answer_for(item)
for item in pending
}
result = graph.invoke(Command(resume=resume_map), config)
Never build the map from IDs cached in a browser tab without checking the latest thread state. A page may have been left open while another reviewer or process already answered the interrupt.
5. Enable strict resume for a LangGraph API server
When using the Python SDK, version 0.4.14 adds a public strict_resume keyword to runs.create, runs.stream, and runs.wait. The following is the resume portion of an async server-backed client. It assumes the thread has already been paused and the reviewer has passed your application's authorization checks:
from langgraph_sdk import get_client
async def submit_approval(
server_url: str,
thread_id: str,
assistant_id: str,
interrupt_id: str,
decision: dict,
):
client = get_client(url=server_url)
# Re-read the thread so the client does not rely on a stale browser payload.
state = await client.threads.get_state(thread_id=thread_id)
current_ids = {item["id"] for item in state["interrupts"]}
if interrupt_id not in current_ids:
raise ValueError("This approval request is no longer pending.")
return await client.runs.create(
thread_id=thread_id,
assistant_id=assistant_id,
command={"resume": {interrupt_id: decision}},
strict_resume=True,
)
The SDK flag asks the server to reject a resume that answers an interrupt that is no longer waiting. Check the server's /info response for the strict_resume capability. If the server is too old, the SDK may send a field the server ignores; do not assume the client-side version alone enables server-side validation.
For local, in-process graph calls, the strict-resume switch currently appears in the implementation as a private configurable key named __pregel_strict_resume. Treat that as an internal implementation detail, not a stable public configuration contract. Prefer the documented SDK parameter with a compatible server, or pin and regression-test any direct use of the private key.
6. Test the failure cases before production
At minimum, write tests for these cases:
- Correct current ID: a response for the current pending interrupt advances the graph.
- Duplicate response: after an interrupt is consumed, submitting its ID again is rejected in strict mode.
- Wrong thread: an ID from one thread cannot be used to answer another thread's pending request.
- Two pending interrupts: a scalar answer is not accepted as a substitute for an ID-to-value mapping.
- Empty map: an empty resume map is rejected rather than treated as a successful answer.
- Node retry: if the node retries after an error, it does not ask the reviewer the same already-answered question again.
- Parallel tasks: every pending interrupt is matched to its own answer.
- Old checkpoint: test a checkpoint created by the version you are upgrading from, because the implementation includes compatibility handling for older interrupt IDs.
When strict mode rejects a response, handle UnknownInterruptError as a stale-state conflict: fetch the latest thread state, refresh the review UI, and ask the reviewer to act on the currently pending request. Do not blindly retry the same payload.
7. Choose the right persistence and deployment model
| Option | Best fit | Trade-off |
|---|---|---|
InMemorySaver | Unit tests and disposable examples | State disappears when the process exits. |
SqliteSaver | Local development and lightweight single-process deployments | Not a multi-worker production database; protect the file and back it up. |
| PostgreSQL checkpointer | Multi-worker services and shared durable state | Requires database operations, migrations, backups, and access control. |
| LangGraph API server | Applications that need thread/run APIs and server-managed persistence | Server and SDK versions must be compatible; verify advertised capabilities. |
The SQLite checkpointer documentation describes it as suitable for local development, testing, or lightweight deployments and warns that the synchronous saver does not scale to multiple threads. Do not put a SQLite checkpoint file on a shared network filesystem and assume it is a distributed coordination layer.
8. Security and privacy checklist
- Authenticate the reviewer: resolve the user from a trusted session or token, not from fields in the interrupt payload.
- Authorize every action: verify tenant, role, resource, and approval scope on the server before calling resume.
- Bind approval to immutable content: include a deployment ID and digest of the exact diff or action plan. Recompute the digest immediately before execution; if it changed, require a new approval.
- Use idempotency: give downstream deployment, payment, or database operations an idempotency key. Strict resume prevents mismatched interrupt answers; it does not guarantee that an external side effect runs exactly once.
- Keep side effects after approval: code before
interrupt()may run again when the node restarts. Put irreversible operations in a later node and make them idempotent. - Audit the decision: record actor ID, tenant ID, thread ID, interrupt ID, action digest, decision, timestamp, and policy version.
- Minimize checkpoint contents: do not store passwords, API tokens, private keys, or unnecessary personal data in state or interrupt payloads.
- Protect checkpoint storage: restrict database/file permissions, encrypt backups, set retention, and follow the checkpointer's serialization security guidance. For SQLite, review the package's
LANGGRAPH_STRICT_MSGPACKguidance. - Protect the resume endpoint: use CSRF protection for cookie-authenticated browser flows, rate limits, tenant isolation, and server-side ownership checks.
Trade-offs and limitations
- Strict resume turns silent stale-response handling into an explicit error. Your client must be ready to refresh state and show a conflict message.
- Strict resume is opt-in. A client flag cannot compensate for a server that does not advertise support.
- Per-interrupt IDs improve matching; they do not decide whether a reviewer is allowed to approve.
- Interrupt IDs are runtime identifiers, not long-term business approval IDs. Store your own durable approval record for audit and workflow reporting.
- Nested graph and replay behavior has implementation-specific boundaries. The strict-resume pull request documents cases where the root-level check is skipped, so test your exact graph topology and checkpoint replay paths.
- Response schemas validate data shape, not business rules or user identity.
- SQLite is convenient for a local walkthrough, not a substitute for a production shared checkpointer.
Troubleshooting
UnknownInterruptError: not_pending
The submitted ID is not waiting on the current checkpoint, or it has already been consumed. Fetch the latest thread state and render the currently pending request. Do not replay an old browser response.
UnknownInterruptError: ambiguous
More than one interrupt is pending but the caller supplied a single value. Submit a mapping from each pending interrupt ID to its answer.
The SDK accepts strict_resume=True, but stale requests still behave as before
Check the installed SDK version and the server's /info capability response. The SDK 0.4.14 change is client-side support for a server feature; the server must implement it too.
The node repeats work after resume
This is expected node replay behavior. Move non-idempotent work after the interrupt or protect it with a stable idempotency key. Avoid sending email, charging a card, or deploying code before a pause point.
Old checkpoints behave differently
Test an actual checkpoint created by the previous version. The new ID implementation includes a compatibility fallback for older saved IDs, but clients should still refresh and use the IDs returned by the current state.
State vanishes after restarting the app
You may be using InMemorySaver. Switch to a persistent checkpointer and verify that every run uses the same stable thread ID.
FAQ
Does LangGraph 1.2.20 enable strict resume by default?
No. The strict validation mode is opt-in. The Python SDK 0.4.14 exposes strict_resume=True for server-backed runs when the server advertises support.
Do repeated interrupts in one node now have different IDs?
Yes. The first interrupt keeps the task's original ID; subsequent interrupts in that task receive IDs that account for the number of answers already recorded.
Can I still resume with Command(resume=True)?
Yes, a single value remains useful when exactly one interrupt is pending. When several interrupts are pending, use an ID-to-answer mapping.
Does strict resume stop duplicate payments or deployments?
No. It validates whether the answer matches a pending interrupt. Your downstream operation still needs authorization, immutable action binding, and idempotency.
Can I use this with a local model such as Ollama?
Yes. LangGraph's interrupt and checkpoint behavior is independent of the model provider. A local Ollama model can propose a plan, while a deterministic application layer pauses for human approval before any privileged tool runs.
Conclusion
LangGraph 1.2.20 makes multi-stage human review easier to reason about by giving repeated interrupts distinct IDs and adding an opt-in way to reject stale resume attempts. SDK 0.4.14 makes that validation easier to request from server-backed Python clients. The safest design still combines fresh pending-state reads, authenticated reviewers, immutable action digests, durable checkpoints, and idempotent side effects. Treat the graph as the workflow engine—not as the authorization system.
Related GyanAangan guides
- Secure Multi-Tenant MCP Servers with Python SDK 2.3.0: OAuth, Scopes & Tenant Isolation — authorization and tenant boundaries for tool servers.
- Ollama Tool Calling with Python: Build a Safe Read-Only Local Codebase Agent — a constrained local agent tool layer.
- Build a Local Notes MCP Server in Python — practical MCP tool design and inspection.
- Trace Local Ollama Apps with OpenTelemetry and Phoenix — instrumenting model-backed application workflows.
Primary sources and version references
- LangGraph 1.2.20 release notes — release date and change list.
- Pull request #9589: per-interrupt IDs — ID generation, retry behavior, and compatibility details.
- Pull request #9574: strict resume — opt-in validation, error reasons, and implementation boundaries.
- LangGraph repository releases — Python SDK 0.4.14 release entry dated November 6, 2026.
- Pull request #9599: SDK strict_resume parameter — SDK method support and server compatibility.
- Official LangGraph interrupts guide — pause, resume, multiple interrupts, and approval patterns.
- Official LangGraph persistence guide — checkpointers, threads, and stores.
- LangGraph Command API reference — resume mapping and command semantics.
- UnknownInterruptError API reference — strict-resume error fields and reasons.
- SQLite checkpointer reference — installation, usage, and deployment limitations.
- LangGraph SDK runs.create reference — server-backed run creation.
Verification note: Version numbers, release dates, API names, and behavior above were checked against the official release notes, merged pull requests, and LangChain documentation available on November 7, 2026. The walkthrough code was reviewed against those references but was not executed against a live LangGraph server or production checkpointer. Validate the exact server build and graph topology in staging before deploying.
