# LangGraph 1.2.15: 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.

Status: tested 2026-10-15 · Python 3.13 (tested) · License: MIT · Tier: Free
Download: https://tryagentsignal.com/downloads/langgraph-hitl-approval-starter.zip

## 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.

## 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.

## 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.

## Code

### hitl_approval.py

```python
"""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.txt

```
langgraph==1.2.15
langgraph-checkpoint==4.3.0

```

### run_output.txt

```
== 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'}

```
