Skip to main content
Scenario Article

Human-in-the-Loop Workflows with LangGraph: Interrupts, Approvals, and Async Execution

The Autonomous Agent Risk: When Acting Without Permission Goes Wrong

Abstract Algorithms•Mar 28, 2026
💡 Executive Summary

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

1

Stage 1 — HITL Fundamentals: interrupt(), NodeInterrupt, and the Checkpointer Requirement

Action / Decision:

Three primitives make human-in-the-loop possible in LangGraph.

Component Behavior:

Understanding what each one does — and what it requires — prevents the most common configuration mistakes.

Observable Output: 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.
Read Full Stage in Deep Dive ↓
📖

Deep Dive

Detailed Architectural Analysis

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.

Share this case study

Discuss architecture trade-offs and lessons with your team.

Share:XLinkedInReddit