LangGraph interrupt(): Pause Agents for Human Approval — ContentBuffer guide

LangGraph interrupt(): Pause Agents for Human Approval

K
Kodetra Technologies··5 min read Intermediate

Summary

Add approval gates to your AI agents with LangGraph interrupt() and checkpointers.

Why a Pause Button Beats a Smarter Prompt

Production agents fail in two ways: they confidently take an action you didn't sanction, or they refuse so often they're useless. The fix is not a better system prompt — it's a pause point. LangGraph 1.2 gives you that pause in one line: interrupt(). The graph freezes, you inspect the proposed action, you approve, edit, or reject, and execution resumes from the exact same state. No re-running the LLM, no lost context.

In this guide you'll build a flight-booking agent that calls a tool, hits an interrupt() right before the booking is committed, and resumes only after a human signs off. By the end you'll know how to wire MemorySaver, raise an interrupt with a payload, and resume with Command(resume=...) — the same pattern you'd ship to production behind an approval queue.

Prerequisites

  • Python 3.10+ and a working virtualenv
  • An Anthropic API key (or OpenAI — the pattern is identical)
  • Comfort reading a small StateGraph; we'll keep it to two nodes
pip install -U langgraph langchain-anthropic langgraph-checkpoint

Step 1 — Define the State and a Risky Tool

The state is a typed dict that flows through every node. Our tool is a fake book_flight that we want a human to approve before it commits.

from typing import TypedDict, Optional
from langchain_anthropic import ChatAnthropic
from langchain_core.tools import tool

class State(TypedDict):
    user_request: str
    proposed_action: Optional[dict]
    booking_confirmation: Optional[str]

@tool
def book_flight(origin: str, destination: str, date: str, price_usd: float) -> str:
    """Books a flight. Charges the user's saved card."""
    # imagine a real Stripe + airline API call here
    return f"BOOKED {origin}->{destination} on {date} for ${price_usd:.2f}"

Step 2 — The Planner Node

The planner asks the LLM what it wants to do and stuffs the structured tool call into proposed_action. We do not execute the tool here — that's the whole point.

llm = ChatAnthropic(model="claude-sonnet-4-6", temperature=0).bind_tools([book_flight])

def planner(state: State) -> dict:
    msg = llm.invoke(
        f"User wants: {state['user_request']}. "
        "Propose exactly one book_flight tool call."
    )
    call = msg.tool_calls[0]   # {"name": ..., "args": {...}, "id": ...}
    return {"proposed_action": call}

Step 3 — The Approval Node with interrupt()

interrupt() takes any JSON-serializable payload. Whatever you pass becomes the value the caller sees when the graph pauses. The return value of interrupt() is whatever the caller passes back via Command(resume=...).

from langgraph.types import interrupt, Command

def approval_gate(state: State) -> dict:
    action = state["proposed_action"]
    decision = interrupt({
        "kind": "approve_booking",
        "summary": f"Book {action['args']['origin']}->{action['args']['destination']} "
                   f"for ${action['args']['price_usd']}?",
        "action": action,
    })

    if decision["choice"] == "reject":
        return {"booking_confirmation": "Rejected by user: " + decision.get("reason", "")}
    if decision["choice"] == "edit":
        action = {**action, "args": {**action["args"], **decision["edits"]}}

    result = book_flight.invoke(action["args"])
    return {"booking_confirmation": result}

Three branches: reject records the reason and bails, edit merges the human's tweaks into the args, and the default approve falls through to the real tool call.

Step 4 — Wire the Graph with a Checkpointer

A checkpointer is non-optional: interrupt() serializes the state to it so the graph can resume in a fresh process if it needs to. MemorySaver is fine for development; swap to PostgresSaver in production.

from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver

g = StateGraph(State)
g.add_node("planner", planner)
g.add_node("approval_gate", approval_gate)
g.add_edge(START, "planner")
g.add_edge("planner", "approval_gate")
g.add_edge("approval_gate", END)

app = g.compile(checkpointer=MemorySaver())

Step 5 — Run It, Hit the Interrupt, Resume

Invoking the graph returns when it finishes or when an interrupt is raised. You inspect state.tasks[*].interrupts, get the human decision, then resume with Command(resume=...).

config = {"configurable": {"thread_id": "booking-42"}}

first = app.invoke(
    {"user_request": "Cheap flight LAX to JFK next Friday"},
    config=config,
)
print(first)

Example output:

{
  "user_request": "Cheap flight LAX to JFK next Friday",
  "proposed_action": {
     "name": "book_flight",
     "args": {"origin": "LAX", "destination": "JFK",
              "date": "2026-05-29", "price_usd": 187.0},
     "id": "toolu_01..."
  },
  "__interrupt__": [{
     "value": {"kind": "approve_booking",
               "summary": "Book LAX->JFK for $187.0?",
               "action": {...}},
     "id": "..."
  }]
}

Now resume. Pass back a structured decision — the same config with the same thread_id tells LangGraph which paused run to wake up.

final = app.invoke(
    Command(resume={"choice": "approve"}),
    config=config,
)
print(final["booking_confirmation"])
# -> BOOKED LAX->JFK on 2026-05-29 for $187.00

Reject path:

app.invoke(
    Command(resume={"choice": "reject", "reason": "Found cheaper on Kayak"}),
    config={"configurable": {"thread_id": "booking-43"}},
)
# booking_confirmation = "Rejected by user: Found cheaper on Kayak"

Edit path — bump the date one day later:

app.invoke(
    Command(resume={"choice": "edit", "edits": {"date": "2026-05-30"}}),
    config={"configurable": {"thread_id": "booking-44"}},
)

Common Pitfalls

  • Forgetting the checkpointer. app.compile() with no checkpointer= will raise as soon as interrupt() fires. Always pass one.
  • Reusing thread_id across runs. Each interrupted run is keyed on thread_id. Start a fresh thread per user task or your resume will land in the wrong conversation.
  • Putting side effects before interrupt(). Anything before interrupt() in the same node runs every time the node resumes. Move charges, emails, or DB writes after the interrupt.
  • Non-serializable payloads. The interrupt payload is pickled to the checkpointer. Stick to dicts, lists, strings, numbers, booleans — no live SQLAlchemy sessions or open file handles.
  • Approving in a different process. That's fine, but the checkpointer must be shared. MemorySaver is per-process; use PostgresSaver or SqliteSaver for cross-process resumes.

Quick Reference

What you wantHow to do it
Pause the graphCall interrupt(payload) inside a node
Resume after approvalapp.invoke(Command(resume=value), config=same_config)
Inspect paused stateapp.get_state(config).tasks[0].interrupts
Persist across processesUse PostgresSaver or SqliteSaver
Cancel a paused runStart a new thread, or call app.update_state(...) with override

Where to Go Next

  • Swap MemorySaver for PostgresSaver and run two workers against the same Postgres — you'll see one resume a thread the other paused.
  • Add a timeout policy: a background job that calls app.update_state to auto-reject interrupts older than N minutes.
  • Wire the interrupt payload into a Slack approval message with Approve / Edit / Reject buttons. Each click POSTs back the matching Command(resume=...).
  • Read the LangGraph docs on dynamic interrupts — you can decide per-call whether to pause based on the tool args (e.g. only pause for bookings over $500).

Ship it: pick one tool in your agent that can spend money or send a message, and put an interrupt in front of it before Friday.

Comments

Subscribe to join the conversation...

Be the first to comment