Human-in-the-Loop Workflows with LangGraph: Interrupts, Approvals, and Async Execution
The Autonomous Agent Risk: When Acting Without Permission Goes Wrong
Pause LangGraph agents mid-run with interrupt(), get human approval, resume with Command.
🎯 System Scenario & Requirements
System Goal
Pause LangGraph agents mid-run with interrupt(), get human approval, resume with Command.
Actors & Components
- Application or API tier
- Core service under design
- Operator or SRE observer
Functional Requirements
- TLDR: Pause LangGraph agents mid-run with interrupt(), get human approval, resume with Command.
- Your autonomous coding agent refactored the authentication module while you were in a meeting.
- As LangGraph agents gain access to real tools — GitHub APIs, database write operations, email senders, cloud billing — each step they take can have irreversible real-world consequences .
Constraints & Assumptions
- Safety Adding an interrupt to a workflow introduces unbounded latency .
- A fully autonomous graph completes in seconds; a human-gated one can sit frozen for hours.
⚡ Execution Stages & Decision Trace
Stage 1 — HITL Fundamentals: interrupt(), NodeInterrupt, and the Checkpointer Requirement
Three primitives make human-in-the-loop possible in LangGraph.
Understanding what each one does — and what it requires — prevents the most common configuration mistakes.
Deep Dive
Scenario context
Pause LangGraph agents mid-run with interrupt(), get human approval, resume with Command.
Your autonomous coding agent refactored the authentication module while you were in a meeting. It looked right to the LLM. It broke production.
This is not a hypothetical. As LangGraph agents gain access to real tools — GitHub APIs, database write operations, email senders, cloud billing — each step they take can have…
This walkthrough focuses on The Autonomous Agent Risk: When Acting Without Permission Goes Wrong.
Before making a design choice, clarify:
- Your autonomous coding agent refactored the authentication module while you were in a meeting.
- As LangGraph agents gain access to real tools — GitHub APIs, database write operations, email senders, cloud billing — each step they take can have irreversible real-world consequences .
- An agent that deletes the wrong S3 bucket, commits a breaking change to main, or triggers a $2,000 API bill does not stop to ask for permission.
Concepts in play: llm-agents-and-langgraph, graph-traversal-algorithms, jvm-internals-and-garbage-collection, stack-and-queue-data-structures
Stage 1 — HITL Fundamentals: interrupt(), NodeInterrupt, and the Checkpointer Requirement
- Three primitives make human-in-the-loop possible in LangGraph.
- Understanding what each one does — and what it requires — prevents the most common configuration mistakes.
- interrupt() — The Primary Pause Mechanism interrupt() is a function you call inside a node to pause the graph and return control to the caller with a payload.
- The graph stops at exactly that line.
from langgraph.types import interrupt
def approval_node(state: AgentState):
# Graph pauses here; payload is sent to the caller
human_decision = interrupt({
"question": "Approve this action?",
"proposed_action": state["proposed_action"],
})
# Execution resumes here after the human responds
return {"approved": human_decision == "approve"}
from langgraph.errors import NodeInterrupt
def review_node(state: AgentState):
if not state.get("human_reviewed"):
# Older pattern: raise an exception to interrupt
raise NodeInterrupt(f"Please review proposed changes: {state['proposed_action']}")
return {"status": "reviewed"}
Observable outcome: The graph stops at exactly that line.
Stage 2 — Building an Approval Workflow: Pause, Inspect, Approve, Resume
- The full HITL lifecycle has four phases.
- Here is the complete flow in code.
- The checkpointer (D → J) is the invisible bridge that makes both invoke() calls part of the same workflow.
- Phase 1 — Invoke the graph (first run): The graph runs until it hits interrupt() .
graph TD
A([Start: invoke graph]) --> B[analyze_node runs]
B --> C[approval_node hits interrupt]
C --> D{Checkpointer saves frozen state}
D --> E([Return __interrupt__ to caller])
E --> F[Human reviews payload]
F --> G{Decision}
G -- approve --> H[invoke Command resume=approve]
G -- reject --> I[invoke Command resume=reject]
H --> J[Checkpointer restores state]
I --> J
J --> K[approval_node resumes; interrupt returns decision]
K --> L[apply_node executes]
L --> M([Graph completes])
config = {"configurable": {"thread_id": "deploy-approval-001"}}
result = graph.invoke({"task": "deploy service to production"}, config)
Observable outcome: Phase 1 — Invoke the graph (first run): The graph runs until it hits interrupt() .
Stage 3 — Deep Dive: How LangGraph Freezes and Resumes Graph Execution
- The Internals When interrupt() is called inside a node, three things happen in sequence: 1.
- interrupt() raises a special internal exception ( GraphInterrupt ) that the LangGraph executor catches at the top of the execution loop.
- This unwinds the call stack cleanly without corrupting state.
- Before returning to the caller, the executor serializes the full state snapshot — including the exact values of all state keys at the moment of interruption — and writes it to the checkpointer under the current thread i…
def deploy_node(state: AgentState):
# ✅ Safe: pure computation before interrupt
proposed = build_deploy_plan(state["task"])
# Graph pauses here — code above runs again on resume (idempotent is fine)
decision = interrupt({"plan": proposed})
# ✅ Safe: side effects AFTER interrupt, only execute once (after resume)
if decision == "approve":
call_deploy_api(proposed) # Only called post-resume
return {"deployed": decision == "approve"}
Observable outcome: Before returning to the caller, the executor serializes the full state snapshot — including the exact values of all state keys at the moment of interruption — and writes it to the checkpointer under the current thread i…
Stage 4 — The Interrupt-Resume Cycle: Full Lifecycle in One Diagram
- The diagram makes two non-obvious points explicit: the checkpointer is the bridge between the two invoke() calls, and approval node is re-entered from its beginning on resume — it does not continue from a saved instruct…
sequenceDiagram
participant App as Application Layer
participant Graph as LangGraph Executor
participant Node as Agent Node
participant CP as Checkpointer
participant Human as Human Reviewer
App->>Graph: invoke(initial_state, config)
Graph->>Node: execute analyze_node()
Node-->>Graph: returns state update
Graph->>Node: execute approval_node()
Node->>Graph: interrupt(proposed_action)
Graph->>CP: save_checkpoint(thread_id, state_snapshot)
Graph-->>App: return {"__interrupt__": [payload]}
App->>Human: display(payload[proposed_action])
Human-->>App: "approve" / "reject" / edited_value
App->>Graph: invoke(Command(resume=decision), config)
Graph->>CP: restore_checkpoint(thread_id)
Graph->>Node: re-enter approval_node(); interrupt() returns decision
Node-->>Graph: returns {"approved": True}
Graph->>Node: execute apply_node()
Node-->>Graph: returns {"applied": True}
Graph-->>App: final state
Observable outcome: The diagram makes two non-obvious points explicit: the checkpointer is the bridge between the two invoke() calls, and approval node is re-entered from its beginning on resume — it does not continue from a saved instruct…
Stage 5 — Real-World Applications: Where Human-in-the-Loop Is Non-Negotiable
- Autonomous DevOps Pipelines A LangGraph agent monitors production metrics, detects anomalies, and generates a runbook of remediation steps.
- Steps like "restart service" might auto-execute, but "scale down the database replica" triggers an interrupt() .
- The on-call engineer receives the proposed change in Slack, approves or rejects it with one click, and the agent resumes.
- Input: Prometheus alert payload, current service topology Interrupt payload: {"action": "scale down", "target": "db-replica-3", "reason": "p99 latency normal"} Human decision: Approve or reject with a comment added via…
Observable outcome: Input: Prometheus alert payload, current service topology Interrupt payload: {"action": "scale down", "target": "db-replica-3", "reason": "p99 latency normal"} Human decision: Approve or reject with a comment added via…
Stage 6 — Practical Example: PR Review Agent That Asks Before Applying Changes
- This agent demonstrates the complete HITL lifecycle in a scenario where the stakes are high enough to justify every pause: code diffs that, if applied incorrectly, break production.
- The PR review scenario was chosen because it maps cleanly to the three primitives covered in this post — interrupt() for pausing, Command(resume=...) for resuming, and update state() for human correction — each one trig…
- As you read through the nodes, watch for the interrupt() call inside review node and trace how the payload flows back as the return value when the graph resumes — that handoff is the mechanism that makes the entire patt…
- Running the agent — Phase 1 (invoke and receive interrupt): Phase 2 (human reviews and resumes): The thread ID "pr-42-review" binds the two invoke() calls together.
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI
# --- State schema ---
class PRReviewState(TypedDict):
pr_number: int
diff: str
analysis: str
proposed_changes: list[str]
approved: bool
applied: bool
llm = ChatOpenAI(model="gpt-4o")
# --- Node 1: Analyze the diff ---
def analyze_pr(state: PRReviewState) -> dict:
response = llm.invoke(
f"Analyze this code diff and identify specific issues:\n{state['diff']}"
)
return {"analysis": response.content}
# --- Node 2: Generate concrete change proposals ---
def propose_changes(state: PRReviewState) -> dict:
response = llm.invoke(
f"Based on this analysis, write 3 specific, actionable code changes:\n{state['analysis']}"
)
changes = [line for line in response.content.split("\n") if line.strip()]
return {"proposed_changes": changes[:3]}
# --- Node 3: Interrupt for human approval ---
def human_approval(state: PRReviewState) -> dict:
# Graph pauses here — payload goes to the caller
decision = interrupt({
"message": "Review proposed changes. Reply 'approve' to proceed or 'reject' to cancel.",
"pr_number": state["pr_number"],
"proposed_changes": state["proposed_changes"],
})
return {"approved": decision.strip().lower() == "approve"}
# --- Node 4: Apply approved changes ---
def apply_changes(state: PRReviewState) -> dict:
if not state["approved"]:
print(f"PR #{state['pr_number']}: changes rejected by reviewer.")
return {"applied": False}
# Re-validate before committing (guard against stale state)
print(f"Applying {len(state['proposed_changes'])} changes to PR #{state['pr_number']}:")
for change in state["proposed_changes"]:
print(f" → {change}")
# In production: call GitHub API, run git apply, post review comment, etc.
return {"applied": True}
# --- Build the graph ---
builder = StateGraph(PRReviewState)
builder.add_node("analyze_pr", analyze_pr)
builder.add_node("propose_changes", propose_changes)
builder.add_node("human_approval", human_approval)
builder.add_node("apply_changes", apply_changes)
builder.add_edge(START, "analyze_pr")
builder.add_edge("analyze_pr", "propose_changes")
builder.add_edge("propose_changes", "human_approval")
builder.add_edge("human_approval", "apply_changes")
builder.add_edge("apply_changes", END)
checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "pr-42-review"}}
sample_diff = "- def login(username, password):\n+ def login(username: str, password: str) -> bool:"
result = graph.invoke(
{"pr_number": 42, "diff": sample_diff, "approved": False, "applied": False},
config
)
# The graph paused at human_approval
payload = result["__interrupt__"][0].value
print(payload["message"]) # "Review proposed changes..."
print(payload["proposed_changes"]) # ["Add type annotations", ...]
Observable outcome: Running the agent — Phase 1 (invoke and receive interrupt): Phase 2 (human reviews and resumes): The thread ID "pr-42-review" binds the two invoke() calls together.
End-to-end outcome
After working through the stages, you should be able to explain:
- Always persist your checkpointer before going to production.
- A process restart during an interrupt wipes all frozen thread states.
Alternatives considered
Decision Guide: Full Autonomy vs Interrupt-on-Action vs Step-by-Step Approval
Decision Guide: Full Autonomy vs Interrupt-on-Action vs Step-by-Step Approval
Failure and degraded modes
Performance vs. Safety
- Detection: Adding an interrupt to a workflow introduces unbounded latency .
- User or operator impact: Adding an interrupt to a workflow introduces unbounded latency .
Failure Mode 1 — The Deadlocked Graph
- Detection: A thread is interrupted and nobody resumes it.
- User or operator impact: A thread is interrupted and nobody resumes it.
Failure Mode 2 — Stale State on Late Resume
- Detection: The LLM proposed changes based on a codebase snapshot from 09:00.
- User or operator impact: The LLM proposed changes based on a codebase snapshot from 09:00.
Failure Mode 3 — Multiple Interrupts in One Thread
- Detection: If a graph has two interrupt() calls in sequence, each one halts and resumes independently, in order.
- User or operator impact: If a graph has two interrupt() calls in sequence, each one halts and resumes independently, in order.
