Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Build LangGraph 1.0 human-in-the-loop approval flows with `interrupt_before` / `interrupt_after` and `Command(resume=...)` — JSON-serializable state, clean resume semantics, and UI wiring for approval decisions. Use when adding an approval gate before an expensive tool call, wiring a Slack/web UI for agent approvals, or debugging a graph that crashes on interrupt. Trigger with "langgraph human in loop", "langgraph interrupt_before", "langgraph approval flow", "Command resume", "langgraph HITL".
.claude/skills/jeremylongshore-langchain-langgraph-human-in-loop/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-08 | ✗→✓ | ▲ Improved | 304% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 171% | 0% |
| case-13 | ✗→✓ | ▲ Improved | 123% | 0% |
| case-14 | ✗→✓ | ▲ Improved | 148% | 0% |
| case-16 | ✗→✓ | ▲ Improved | 115% | 0% |
A team adds interrupt_before=["send_email"] to require a human approval before the email goes out. First integration test crashes at the interrupt boundary with:
TypeError: Object of type datetime is not JSON serializableThe culprit is two nodes upstream: a classify node stashed "received_at": datetime.utcnow() into state. Every node-level unit test passed because node completion does not serialize state — only the checkpointer does, and only at supersteps that include an interrupt. The failure is invisible until interrupt time (P17).
A week later the resume path ships. The human reviews the draft, clicks "approve with edits," and the backend runs:
pythongraph.invoke(Command(update={"messages": [corrected_msg]}, resume="approved"), config)
The prior 47 messages vanish. messages was typed as plain list[AnyMessage] with no reducer, so update replaces the field instead of appending (P18).
This skill covers: three interrupt styles (interrupt_before, interrupt_after, inline interrupt()), the JSON-only state invariant with a pre-interrupt scanner, the Command(resume=...) / Command(update=..., resume=...) contract, an approval UI wire format (GET pending / POST decision with optimistic concurrency), safe-cancellation routing to END, and the tradeoff between native interrupts and a separate approval service. Pin: langgraph 1.0.x, langgraph-checkpoint 2.0.x. Pain-catalog anchors: P17, P18 (adjacent: P16, P20).
langgraph >= 1.0, < 2.0MemorySaver (dev), PostgresSaver (prod), or SqliteSaver (single-box)thread_id contract at the app boundary (see langchain-langgraph-checkpointing)langchain-langgraph-basics — nodes, edges, TypedDict state with reducersLangGraph 1.0 exposes three interrupt mechanisms. They are not interchangeable.
| Style | Syntax | Use when | |-------|--------|----------| | interrupt_before=[node] | compile(interrupt_before=["send_email"]) | Review inputs before an irreversible tool. Graph pauses before node runs. State shown is the input. | | interrupt_after=[node] | compile(interrupt_after=["draft_email"]) | Review output of a node (e.g., an LLM draft). Graph pauses after node completes. | | Inline interrupt() | Inside a node: decision = interrupt({"kind": "..."}) | Structured prompt mid-node with custom payload. Most flexible; lives in node code. |
Rule of thumb: prefer interrupt_before for hard gates (tool must not run without approval). Use interrupt_after for review loops (draft → approve → send). Use inline interrupt() when the prompt varies on intermediate computation.
Typical interrupt round-trip latency in production is 50-300 ms from pause to checkpoint write (local Postgres) plus UI time; budget 1-5 s total for a Slack-based approval. Checkpoint row sizes average 2-20 KB on small graphs and cap at ~1 MB on PostgresSaver before historical checkpoints need pruning.
See Interrupt Decision Tree for full criteria, multiple-interrupt-per-graph patterns, and the interrupt-vs-tool comparison.
Checkpointers serialize state to JSON on every superstep. Any non-JSON type raises TypeError at the interrupt boundary — not at the offending node. Canonical offenders:
| Type | Fix | |------|-----| | datetime / date | dt.isoformat() — ISO 8601 string | | bytes | base64.b64encode(b).decode() | | set | sorted(s) | | Pydantic BaseModel with non-primitive fields | .model_dump(mode="json") | | Custom classes | dataclasses.asdict(obj) or vars(obj) | | numpy.ndarray | .tolist() | | decimal.Decimal | str(d) or float(d) (lossy) | | float("nan") / float("inf") | None (JSON forbids them; some savers crash on allow_nan=False) |
Ship a pre-interrupt scanner in dev and CI:
pythonimport json from typing import Any class NonSerializableStateError(TypeError): """Raised when state contains values the checkpointer cannot serialize.""" def assert_state_is_json_serializable(state: dict[str, Any], *, path: str = "state") -> None: """Walk state depth-first and raise a typed error naming the offending key path.""" _walk(state, path) def _walk(v: Any, path: str) -> None: if v is None or isinstance(v, (bool, int, float, str)): return if isinstance(v, list): for i, item in enumerate(v): _walk(item, f"{path}[{i}]") return if isinstance(v, dict): for k, val in v.items(): _walk(val, f"{path}.{k}") return raise NonSerializableStateError( f"{path} is {type(v).__name__}, not JSON-serializable. " f"Convert at node boundary." )
Call assert_state_is_json_serializable(state) at the end of every node preceding an interrupt-flagged node, or attach as LangGraph middleware. In CI, run the full graph to interrupt against a fixture that exercises every branch — the only way to catch P17 before prod.
See State Serialization for Interrupts for the full forbidden-types list, the Pydantic-in-state pattern, and the integration-test harness.
Two shapes. They are not equivalent.
pythonfrom langgraph.types import Command # Shape A — resume only: human approved as-is graph.invoke(Command(resume="approved"), config) # Shape B — update + resume: human edited state mid-graph graph.invoke( Command(update={"recipient": "new@example.com"}, resume="approved"), config, )
resume="..." is the value returned from inline interrupt() inside the node (if any). For interrupt_before / interrupt_after, no node reads resume, but the checkpoint records it for audit.
update={...} merges into state via the reducer declared in the TypedDict. Without a reducer, update replaces the field (P18). Always annotate list and dict state:
pythonfrom typing import Annotated, TypedDict from langchain_core.messages import AnyMessage from langgraph.graph.message import add_messages class AgentState(TypedDict): messages: Annotated[list[AnyMessage], add_messages] # append, not replace approvals: Annotated[list[dict], lambda l, r: l + r] # custom append reducer draft: Annotated[dict, lambda l, r: {**l, **r}] # dict merge reducer last_decision: str # scalar: replace is fine
See Resume Patterns for the five canonical resume shapes (plain approve, approve with edits, reject to END, partial approval, inline-interrupt structured return), the reducer cookbook, and the audit-log write order.
Two HTTP endpoints. Keep them boring.
GET /approvals/pending lists paused threads:
json[ { "thread_id": "conv-abc123", "checkpoint_id": "01JABC...", "interrupted_at": "2026-04-21T15:32:11Z", "node": "send_email", "state_diff": {"draft": {"to": "user@example.com", "subject": "Welcome"}} } ]
POST /approvals/<thread-id>/decision applies the decision:
json{ "decision": "approve" | "reject" | "edit", "edits": {"recipient": "corrected@example.com"}, "approver": "jeremy@intentsolutions.io", "reason": "Verified against ticket INT-4821", "expected_checkpoint_id": "01JABC...", "idempotency_key": "c2f5e8a0-..." }
Optimistic concurrency (the expected_checkpoint_id check) matters the moment two approvers open the same thread in two browser tabs. Without it, the second click silently overwrites the first. Return 409 Conflict on mismatch; UI refreshes.
Server-side flow: authz → idempotency dedupe → checkpoint check → audit-log write (BEFORE mutation) → build Command → graph.ainvoke(cmd, config) → audit-log finalize.
See Approval UI Wiring for the full HTTP contract with status codes, FastAPI implementation, Slack Block Kit mapping, state-diff redaction, and an audit-log schema compatible with SOC2 evidence requirements.
END on rejectWhen the human rejects, the gated node must NOT execute. Two clean patterns:
Pattern A — conditional edge after the interrupted node (preferred):
pythonfrom langgraph.graph import END def route_after_approval(state: AgentState) -> str: if state.get("last_decision") == "rejected": return END return "send_email" builder.add_conditional_edges("await_approval", route_after_approval, { "send_email": "send_email", END: END, })
Pattern B — Command(goto=END) at resume:
pythongraph.invoke(Command(resume="rejected", goto=END), config)
Prefer Pattern A in production: graph topology stays the source of truth, audit replays work without the UI. Always log the rejection to the checkpoint via Command(update={"last_decision": "rejected", "reject_reason": ...}) BEFORE routing to END — otherwise the audit trail lives only in the UI DB.
| Dimension | LangGraph interrupts | Separate approval service | |-----------|---------------------|---------------------------| | Latency | 50-300 ms pause + human time | Human time + queue latency | | State coherence | Single source of truth (checkpoint) | Two systems to reconcile | | Concurrency | Checkpoint-based optimistic locking | Whatever the queue provides | | Multi-graph | Per-graph, per-thread | Centralized policy engine | | Observability | get_state() + checkpoint history | Separate audit system | | Failure mode | JSON-serialization at interrupt (P17) | Network partition between services | | Best for | Single LangGraph app, 1-10 approval types, <1k/day | Multi-app enterprise, complex RBAC, 10k+/day |
Single LangGraph app with fewer than a dozen approval types: native interrupts are simpler and more reliable. Cross-app approval platform with escalations, delegations, and SLAs: run a dedicated service and call it from a tool, not from an interrupt.
interrupt_before / interrupt_after lists, or inline interrupt() calls where payload structure mattersdatetime → ISO strings, bytes → base64, Pydantic → .model_dump(mode="json"), custom classes → dictsTypedDict state with explicit reducers on every list and dict fieldNonSerializableStateError with a key pathexpected_checkpoint_id optimistic-concurrency check and idempotency_key dedupeEND via conditional edge (Pattern A) with last_decision recorded in state for auditapprover, reason, thread_id, checkpoint_id_before, checkpoint_id_after| Error | Cause | Fix | |-------|-------|-----| | TypeError: Object of type datetime is not JSON serializable at interrupt | Non-JSON value in state (P17) | Convert at node boundary; add pre-interrupt scanner in CI | | Resume with Command(update={"messages": [new]}) loses history | messages field missing reducer (P18) | Annotate as Annotated[list[AnyMessage], add_messages] | | ValueError: Thread ... has no interrupted nodes on resume | Graph already ran to completion, or thread_id mismatch | Call graph.get_state(config) first; assert snapshot.next is non-empty | | Human clicks approve, nothing happens | Missing checkpointer on compile() — interrupts require persistence | graph.compile(checkpointer=MemorySaver() or PostgresSaver(...)) | | Two approvers both click approve, second one's edits win silently | No optimistic concurrency | Include expected_checkpoint_id in POST body; return 409 on mismatch | | KeyError: 'configurable' at resume | config dict missing thread_id | config = {"configurable": {"thread_id": tid}} — required by every checkpointer | | Approval UI shows stale state after another approver acted | Cached GET /pending response | Cache-Control: no-store on the pending endpoint | | Graph halts silently after reject | Conditional edge router returned value not in path_map | Include END in path_map; assert router output in keyset |
Email-sending agent that must not send without approval. State carries draft: {to, subject, body}, graph compiles with interrupt_before=["send_email"], resume either invokes the send tool or routes to END on reject. See Resume Patterns for the full worked example including audit-log write order.
Human accepts the recipient but rewrites the subject. Resume is Command(update={"draft": {**state["draft"], "subject": new_subject}}, resume="approved"). Note the spread — without it the draft is replaced. Scalar dicts replace by default; declare a dict reducer to merge partials cleanly. See Resume Patterns.
interrupt() with a custom payloadInside a validate_purchase node, the model has decided to buy three items at USD 450 total. The node calls decision = interrupt({"kind": "confirm_purchase", "items": items, "total": 450}) and the UI reads the payload to render a rich confirmation dialog. On resume, decision is whatever the UI sent via Command(resume={"approved": True, "notes": "..."}). See Interrupt Decision Tree.
GET /pending feeds a cron that posts Block Kit messages with approve/reject buttons. Button callback POSTs to /decision. Slack's interaction payload carries user.id, which becomes approver in the audit log. See Approval UI Wiring for the Block Kit template and signing-secret validation.
Command type referenceinterrupt function referencedocs/pain-catalog.md (entries P16, P17, P18, P20)langchain-langgraph-basics, langchain-langgraph-checkpointing, langchain-middleware-patterns| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 35,093 | 31,546 | -10% | 1 | 1 | 0% | 7,101 | 9,538 | +34% | 0 | 0 | — |
case-02 | fail→fail | 53,339 | 35,847 | -33% | 1 | 1 | 0% | 7,848 | 10,436 | +33% | 0 | 0 | — |
case-03 | pass→pass | 18,900 | 13,050 | -31% | 1 | 1 | 0% | 2,560 | 6,549 | +156% | 0 | 0 | — |
case-04 | fail→fail | 15,112 | 8,855 | -41% | 1 | 1 | 0% | 1,828 | 5,609 | +207% | 0 | 0 | — |
case-05 | pass→pass | 23,336 | 10,834 | -54% | 1 | 1 | 0% | 2,543 | 5,905 | +132% | 0 | 0 | — |
case-06 | pass→pass | 13,573 | 16,183 | +19% | 1 | 1 | 0% | 2,069 | 6,112 | +195% | 0 | 0 | — |
case-07 | pass→pass | 20,395 | 11,765 | -42% | 1 | 1 | 0% | 2,727 | 6,126 | +125% | 0 | 0 | — |
case-08 | fail→pass | 8,381 | 9,744 | +16% | 1 | 1 | 0% | 1,427 | 5,772 | +304% | 0 | 0 | — |
case-09 | pass→pass | 25,676 | 23,639 | -8% | 1 | 1 | 0% | 3,612 | 8,645 | +139% | 0 | 0 | — |
case-10 | pass→pass | 16,059 | 13,295 | -17% | 1 | 1 | 0% | 1,690 | 5,646 | +234% | 0 | 0 | — |
case-22 | pass→pass | 25,008 | 25,152 | +1% | 1 | 1 | 0% | 3,954 | 9,066 | +129% | 0 | 0 | — |
case-11 | pass→pass | 15,479 | 9,638 | -38% | 1 | 1 | 0% | 1,971 | 5,989 | +204% | 0 | 0 | — |
case-12 | fail→pass | 19,299 | 20,954 | +9% | 1 | 1 | 0% | 2,772 | 7,504 | +171% | 0 | 0 | — |
case-13 | fail→pass | 15,915 | 22,513 | +41% | 1 | 1 | 0% | 3,048 | 6,796 | +123% | 0 | 0 | — |
case-14 | fail→pass | 22,972 | 30,312 | +32% | 1 | 1 | 0% | 3,313 | 8,231 | +148% | 0 | 0 | — |
case-15 | fail→fail | 20,969 | 25,924 | +24% | 1 | 1 | 0% | 2,935 | 7,247 | +147% | 0 | 0 | — |
case-16 | fail→pass | 22,271 | 21,248 | -5% | 1 | 1 | 0% | 3,274 | 7,028 | +115% | 0 | 0 | — |
case-17 | pass→pass | 15,419 | 19,204 | +25% | 1 | 1 | 0% | 2,875 | 6,818 | +137% | 0 | 0 | — |
case-18 | pass→pass | 23,097 | 13,355 | -42% | 1 | 1 | 0% | 2,856 | 6,530 | +129% | 0 | 0 | — |
case-19 | pass→pass | 6,628 | 11,457 | +73% | 1 | 1 | 0% | 1,155 | 5,100 | +342% | 0 | 0 | — |
case-20 | pass→pass | 25,523 | 23,512 | -8% | 1 | 1 | 0% | 4,222 | 8,026 | +90% | 0 | 0 | — |
case-21 | pass→pass | 10,021 | 10,106 | +1% | 1 | 1 | 0% | 2,104 | 6,030 | +187% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +23 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.