"Build LangGraph 1.0 human-in-the-loop approval flows with interrupt_before\
Scanned 9/2/2026
Install to Claude Code
npx -y skills add jeremylongshore/tons-of-skills-marketplace --skill langchain-langgraph-human-in-loop --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Langchain Langgraph Human In Loop?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/jeremylongshore-langchain-langgraph-human-in-loop-9b05ea78)More formats (shields.io, HTML) on the badges page.
---
name: langchain-langgraph-human-in-loop
description: "Build LangGraph 1.0 human-in-the-loop approval flows with interrupt_before\
\ /\ninterrupt_after and Command(resume=...) \u2014 JSON-serializable state,\
\ clean\nresume semantics, and UI wiring for approval decisions. Use when adding\
\ an\napproval gate before an expensive tool call, wiring a Slack/web UI for agent\n\
approvals, or debugging a graph that crashes on interrupt.\nTrigger with \"langgraph\
\ human in loop\", \"langgraph interrupt_before\",\n\"langgraph approval flow\"\
, \"Command resume\", \"langgraph HITL\".\n"
allowed-tools: Read, Write, Edit, Bash(python:*)
version: 2.5.0
license: MIT
author: Jeremy Longshore <jeremy@intentsolutions.io>
tags:
- saas
- langchain
- langgraph
- python
- langchain-1.0
- human-in-loop
- approval
- interrupts
compatibility: Designed for Claude Code
---
# LangChain LangGraph Human-in-the-Loop (Python)
## Overview
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 serializable
```
The 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:
```python
graph.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).
## Prerequisites
- Python 3.10+
- `langgraph >= 1.0, < 2.0`
- A checkpointer: `MemorySaver` (dev), `PostgresSaver` (prod), or `SqliteSaver` (single-box)
- A `thread_id` contract at the app boundary (see `langchain-langgraph-checkpointing`)
- Familiarity with `langchain-langgraph-basics` — nodes, edges, `TypedDict` state with reducers
## Instructions
### Step 1 — Choose the interrupt style
LangGraph 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](references/interrupt-decision-tree.md) for full
criteria, multiple-interrupt-per-graph patterns, and the interrupt-vs-tool
comparison.
### Step 2 — Enforce the JSON-serializable state invariant (P17)
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:
```python
import 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](references/state-serialization-for-interrupts.md)
for the full forbidden-types list, the Pydantic-in-state pattern, and the
integration-test harness.
### Step 3 — The resume contract
Two shapes. They are not equivalent.
```python
from 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:
```python
from 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](references/resume-patterns.md) 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.
### Step 4 — Wire the approval UI
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](references/approval-ui-wiring.md) 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.
### Step 5 — Safe cancellation: route to `END` on reject
When the human rejects, the gated node must NOT execute. Two clean patterns:
**Pattern A — conditional edge after the interrupted node (preferred):**
```python
from 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:**
```python
graph.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.
### Step 6 — Native interrupts vs a separate approval service
| 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.
## Output
- Graph compiled with explicit `interrupt_before` / `interrupt_after` lists, or inline `interrupt()` calls where payload structure matters
- JSON-only state: `datetime` → ISO strings, `bytes` → base64, Pydantic → `.model_dump(mode="json")`, custom classes → dicts
- `TypedDict` state with explicit reducers on every list and dict field
- Pre-interrupt state scanner attached as middleware or called at node exits; raises `NonSerializableStateError` with a key path
- Approval HTTP endpoints: GET pending with state diffs, POST decision with `expected_checkpoint_id` optimistic-concurrency check and `idempotency_key` dedupe
- Rejection routes to `END` via conditional edge (Pattern A) with `last_decision` recorded in state for audit
- Audit log written BEFORE state mutation with `approver`, `reason`, `thread_id`, `checkpoint_id_before`, `checkpoint_id_after`
## Error Handling
| 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 |
## Examples
### Approval gate before an expensive tool
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](references/resume-patterns.md) for the full worked example
including audit-log write order.
### Partial approval — approve one argument, edit another
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](references/resume-patterns.md).
### Inline `interrupt()` with a custom payload
Inside 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](references/interrupt-decision-tree.md).
### Slack-driven approval
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](references/approval-ui-wiring.md) for the Block Kit
template and signing-secret validation.
## Resources
- [LangGraph: Human-in-the-loop (concepts)](https://langchain-ai.github.io/langgraph/concepts/human_in_the_loop/)
- LangGraph: How to add human-in-the-loop
- [LangGraph: `Command` type reference](https://langchain-ai.github.io/langgraph/reference/types/#langgraph.types.Command)
- [LangGraph: `interrupt` function reference](https://langchain-ai.github.io/langgraph/reference/types/#langgraph.types.interrupt)
- [LangGraph: Persistence and checkpointers](https://langchain-ai.github.io/langgraph/concepts/persistence/)
- [LangGraph 1.0 release notes](https://blog.langchain.com/langchain-langgraph-1dot0/)
- Pack pain catalog: `docs/pain-catalog.md` (entries P16, P17, P18, P20)
- Related skills: `langchain-langgraph-basics`, `langchain-langgraph-checkpointing`, `langchain-middleware-patterns`
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!