Tested on LangGraph (langgraph 1.2.15, pinned in requirements.txt)
Human-in-the-loop approval graph
A propose → approval → execute StateGraph that pauses with interrupt(), saves the pause in a checkpointer under a thread_id, and resumes with Command(resume=...) down the approve or reject path.
What it shows
- interrupt(): In the approval node, interrupt({...}) pauses the run; invoke() returns {"__interrupt__": [Interrupt(value=...)]}.
- Checkpointer: graph.get_state(cfg).next == ("approval",): InMemorySaver stores the pause under the thread_id.
- Command(resume=...): graph.invoke(Command(resume={...}), cfg) resumes; interrupt() returns the resume value and the node routes with Command(goto="execute" | END, update=...). Both approve and reject paths run.
Code
"""LangGraph HITL: interrupt() pauses a node, checkpointer persists, Command(resume=...) continues.
No LLM involved: the 'proposal' node is deterministic (stands in for a model). All LangGraph code is real."""
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command
class S(TypedDict, total=False):
request: str
action: dict
decision: str
result: str
def propose(state: S):
print(" [propose] runs")
return {"action": {"tool": "delete_records", "table": state["request"], "rows": 1200}}
def approval(state: S):
print(" [approval] node (re)starts") # NOTE: node re-executes from the top on resume
answer = interrupt({"question": "Approve this action?", "action": state["action"]})
if answer.get("approved"):
return Command(goto="execute", update={"decision": "approved"})
return Command(goto=END, update={"decision": "rejected", "result": f"rejected: {answer.get('reason')}"})
def execute(state: S):
a = state["action"]
return {"result": f"executed {a['tool']} on {a['table']} ({a['rows']} rows)"}
b = StateGraph(S)
b.add_node("propose", propose)
b.add_node("approval", approval, destinations=("execute", END))
b.add_node("execute", execute)
b.add_edge(START, "propose"); b.add_edge("propose", "approval"); b.add_edge("execute", END)
graph = b.compile(checkpointer=InMemorySaver())
for tid, resume in [("t-approve", {"approved": True}), ("t-reject", {"approved": False, "reason": "too many rows"})]:
cfg = {"configurable": {"thread_id": tid}}
print(f"== thread {tid} ==")
out = graph.invoke({"request": "customers"}, cfg)
intr = out["__interrupt__"][0]
print(" paused, interrupt payload:", intr.value)
snap = graph.get_state(cfg)
print(" checkpoint next node:", snap.next)
print(" -> human resumes with", resume)
out = graph.invoke(Command(resume=resume), cfg)
print(" final:", {k: out[k] for k in ("decision", "result")})
langgraph==1.2.15 langgraph-checkpoint==4.3.0
== thread t-approve ==
[propose] runs
[approval] node (re)starts
paused, interrupt payload: {'question': 'Approve this action?', 'action': {'tool': 'delete_records', 'table': 'customers', 'rows': 1200}}
checkpoint next node: ('approval',)
-> human resumes with {'approved': True}
[approval] node (re)starts
final: {'decision': 'approved', 'result': 'executed delete_records on customers (1200 rows)'}
== thread t-reject ==
[propose] runs
[approval] node (re)starts
paused, interrupt payload: {'question': 'Approve this action?', 'action': {'tool': 'delete_records', 'table': 'customers', 'rows': 1200}}
checkpoint next node: ('approval',)
-> human resumes with {'approved': False, 'reason': 'too many rows'}
[approval] node (re)starts
final: {'decision': 'rejected', 'result': 'rejected: too many rows'}
MIT License Copyright (c) 2026 Agent Signal Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Upgrade gotchas
- The node re-runs from the top on resume ("[approval] node (re)starts" prints twice). Put side effects after interrupt() or make them idempotent.
- Interrupts need a checkpointer and a thread_id, or you cannot resume.
- A node returning Command(goto=...) should declare destinations= so the graph knows its edges.
- InMemorySaver is lost when the process exits; use langgraph-checkpoint-sqlite or -postgres for pause/resume across restarts.
Free starter pack
The approval-flow starter pack
Tested human-in-the-loop approval starters for LangGraph, OpenAI Agents SDK, Vercel AI SDK and Google ADK, plus the weekly Agent Signal newsletter. Free.
FAQ
How do I pause a LangGraph graph for human approval?
Call interrupt({...}) inside a node. With a checkpointer and thread_id configured, invoke() returns an __interrupt__ payload and the state is saved with next == the paused node.
How do I resume after the human decides?
Call graph.invoke(Command(resume=value), config) with the same thread_id. interrupt() returns that value inside the node.
Why does my node run twice?
On resume the interrupted node re-executes from the top, so code before interrupt() runs again. Keep side effects after it or make them idempotent.
Does the pause survive a restart?
Not with InMemorySaver, which this demo uses. Use a SQLite or Postgres checkpointer for that (not tested here).
Which versions was it tested on?
langgraph 1.2.15 and langgraph-checkpoint 4.3.0 on Python 3.13, on Oct 15, 2026. MIT-licensed.