langchain-langgraph-human-in-loop
Helps add approval gates to LangGraph agents using interrupt mechanisms and resume logic.
Install
mkdir -p .claude/skills/langchain-langgraph-human-in-loop && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/19410" && unzip -o skill.zip -d .claude/skills/langchain-langgraph-human-in-loop && rm skill.zipInstalls to .claude/skills/langchain-langgraph-human-in-loop
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
Build LangGraph 1.0 human-in-the-loop approval flows with `interrupt_before`\Key capabilities
- →Implement interrupt_before and interrupt_after boundaries
- →Manage JSON-serializable state for checkpointers
- →Wire approval UI for agent decisions
- →Handle resume logic with Command objects
How it works
The skill provides patterns for adding human approval gates to LangGraph applications using interrupt boundaries and state management techniques.
Inputs & outputs
When to use langchain-langgraph-human-in-loop
- →Adding approval steps to agents
- →Handling agent interrupts
- →Debugging graph crashes on interrupt
- →Wiring Slack approvals for agents
About this skill
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:
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), orSqliteSaver(single-box) - A
thread_idcontract at the app boundary (seelangchain-langgraph-checkpointing) - Familiarity with
langchain-langgraph-basics— nodes, edges,TypedDictstate 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 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:
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 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.
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": "[email protected]"}, 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:
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 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:
[
{
"thread_id": "conv-abc123",
"checkpoint_id": "01JABC...",
"interrupted_at": "2026-04-21T15:32:11Z",
"node": "send_email",
"state_diff": {"draft": {"to": "[email protected]", "subject": "Welcome"}}
}
]
POST /approvals/<thread-id>/decision applies the decision:
{
"decision": "approve" | "reject" | "edit",
"edits": {"recipient": "[email protected]"},
"approver": "[email protected]",
"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.
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):
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:
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 | Sing |
Content truncated.
When not to use it
- →When the application does not require human-in-the-loop approval
- →When using LangGraph versions incompatible with 1.0
Prerequisites
Limitations
- →Requires strict JSON-serializable state
- →Interrupt round-trip latency depends on checkpointer and UI
How it compares
It addresses specific serialization and state-update pitfalls common in LangGraph 1.0, which are not covered by generic agent tutorials.
Compared to similar skills
langchain-langgraph-human-in-loop side by side with the closest alternatives in the catalog.
| Skill | Installs | Updated | Safety | Difficulty |
|---|---|---|---|---|
| langchain-langgraph-human-in-loop (this skill) | 0 | 10d | Review | Advanced |
| llama-cpp | 21 | 8mo | Review | Intermediate |
| mcp-builder | 136 | 3mo | Review | Advanced |
| langchain | 26 | 8mo | Review | Intermediate |
Try saying
Example prompts that trigger this skill in your AI assistant.
More by jeremylongshore
View all by jeremylongshore →You might also like
llama-cpp
zechenzhangAGI
Runs LLM inference on CPU, Apple Silicon, and consumer GPUs without NVIDIA hardware. Use for edge deployment, M1/M2/M3 Macs, AMD/Intel GPUs, or when CUDA is unavailable. Supports GGUF quantization (1.5-8 bit) for reduced memory and 4-10× speedup vs PyTorch on CPU.
mcp-builder
anthropics
Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).
langchain
zechenzhangAGI
Framework for building LLM-powered applications with agents, chains, and RAG. Supports multiple providers (OpenAI, Anthropic, Google), 500+ integrations, ReAct agents, tool calling, memory management, and vector store retrieval. Use for building chatbots, question-answering systems, autonomous agents, or RAG applications. Best for rapid prototyping and production deployments.
llama-factory
zechenzhangAGI
Expert guidance for fine-tuning LLMs with LLaMA-Factory - WebUI no-code, 100+ models, 2/3/4/5/6/8-bit QLoRA, multimodal support
langgraph
davila7
Expert in LangGraph - the production-grade framework for building stateful, multi-actor AI applications. Covers graph construction, state management, cycles and branches, persistence with checkpointers, human-in-the-loop patterns, and the ReAct agent pattern. Used in production at LinkedIn, Uber, and 400+ companies. This is LangChain's recommended approach for building agents. Use when: langgraph, langchain agent, stateful agent, agent graph, react agent.
ollama-setup
jeremylongshore
Configure auto-configure Ollama when user needs local LLM deployment, free AI alternatives, or wants to eliminate hosted API costs. Trigger phrases: "install ollama", "local AI", "free LLM", "self-hosted AI", "replace OpenAI", "no API costs". Use when appropriate context detected. Trigger with relevant phrases based on skill purpose.