agent_signal
← All templates
LangGraph · 1.2.15 · Python ✓ Tested

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.
Mocked vs real. Nothing is mocked and no LLM is used. The propose node is deterministic Python standing in for a model or tool-calling agent. Graph, interrupt, checkpointer and Command resume are real LangGraph code (langgraph==1.2.15, langgraph-checkpoint==4.3.0). Not tested: create_react_agent / LLM tool calls, durable SQLite/Postgres checkpointers, LangGraph Platform.

Code

hitl_approval.pysrc
"""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")})
requirements.txtsrc
langgraph==1.2.15
langgraph-checkpoint==4.3.0
run_output.txtsrc
== 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'}
LICENSEsrc
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.