Human-in-the-Loop Interrupts

How LangGraph’s interrupt() pauses a running graph mid-step to ask a human before a risky action, and how Command(resume=…) picks it back up.

Author Avatar

Fernando

  ·  9 min read

The problem #

Full autonomy is the wrong default for some actions. An agent that can look up a shipping status on its own is fine to run unattended; one that can charge a card, delete a record, or send a payment is not, no matter how good its judgment usually is. What you want isn’t less capability, it’s a pause: the graph gets to the risky step, stops, and asks a human before it does anything that can’t be undone. Not a retry after the fact, a gate before it.

The concept #

How interrupt() actually pauses a graph #

interrupt(), imported from langgraph.types, is what creates that gate, but it’s worth being precise about what “pause” means here, because it’s not what it sounds like. Calling interrupt() is not a blocking call the way input() or a network request with no timeout is blocking. Nothing sits there waiting. Under the hood, interrupt() raises an internal exception (LangGraph’s own control-flow exception, not something your code is meant to catch) that propagates straight up the call stack, past the node function, past the graph’s own step logic, until the LangGraph runtime itself catches it. That’s the “unwind”: the Python call stack for that node unwinds all the way back to whoever called .invoke(), exactly the way any uncaught exception unwinds a stack, except this one is caught by the runtime instead of crashing the process.

Two things matter about that exception mechanism. First, it deliberately bypasses the graph’s normal error handling: a retry policy attached to the node, or a try/except wrapped around the node’s logic, doesn’t intercept it, because catching arbitrary exceptions would silently swallow interrupts and make a node that’s supposed to pause instead retry or fail. Second, before the runtime hands control back to the caller, it does the one thing that makes resuming possible at all: it writes a checkpoint capturing exactly where the graph stopped, which is why chapter 05’s checkpointer isn’t a nice-to-have here, it’s a hard prerequisite. Without a checkpointer configured at compile time, there is no durable record of “the graph paused at this node, in this thread, with this much state already built.” interrupt() would have nowhere to write that down, and there would be nothing for a later call to resume.

Where the payload goes #

The value you pass to interrupt(), the dict with the question, item, and amount in the example below, travels with that unwind. It doesn’t get returned from the function call (the function call never returns anything on this path, it never gets the chance to). Instead, the runtime attaches it to the result of the .invoke() call that triggered the pause, under a special __interrupt__ key. That’s the entire surface area of “asking a human”: the caller’s .invoke() returns like normal, except the state it hands back carries that extra key, and whatever’s on the other side, a CLI prompt, a Slack message, a review queue, reads result["__interrupt__"] and shows it to a person.

How Command(resume=...) gets back in #

Resuming means calling .invoke() again on the same thread, this time with Command(resume=<value>) instead of a fresh input. LangGraph looks up the latest checkpoint for that thread_id, the one written the instant interrupt() fired, and restores the state as of that pause. Then it re-enters the graph at the node that raised the interrupt.

Here’s the part that trips people up: it doesn’t jump back into the middle of that node’s function, to the line right after interrupt(), the way resuming a paused coroutine or a debugger breakpoint would. It re-runs the entire node from its first line. The only difference this time is that the interrupt() call itself, instead of raising its unwinding exception, simply returns the value you passed to resume, as if it had been an ordinary function call returning ordinary data all along. Any code in the node before that call runs again, for the second time, before execution reaches the interrupt() line and gets the answer it was waiting for.

Seeing it end to end #

Building on the checkpointed graph from chapter 05, here’s a node that pauses before a purchase and a follow-up call that approves it:

 1from langgraph.checkpoint.memory import InMemorySaver
 2from langgraph.graph import StateGraph, START, END
 3from langgraph.types import Command, interrupt
 4from typing import TypedDict
 5
 6
 7class PurchaseState(TypedDict):
 8    item: str
 9    amount: float
10    status: str
11
12
13def request_approval(state: PurchaseState) -> PurchaseState:
14    decision = interrupt(
15        {
16            "question": "Approve this purchase?",
17            "item": state["item"],
18            "amount": state["amount"],
19        }
20    )
21    if not decision:
22        return {"status": "rejected"}
23    return {"status": "approved"}
24
25
26def charge_card(state: PurchaseState) -> PurchaseState:
27    # Only reached once status is "approved". This is where the
28    # actual, irreversible side effect (the charge) belongs.
29    return {"status": "charged"}
30
31
32builder = StateGraph(PurchaseState)
33builder.add_node("request_approval", request_approval)
34builder.add_node("charge_card", charge_card)
35builder.add_edge(START, "request_approval")
36builder.add_edge("request_approval", "charge_card")
37builder.add_edge("charge_card", END)
38
39graph = builder.compile(checkpointer=InMemorySaver())
40config = {"configurable": {"thread_id": "order-42"}}
41
42# First call: runs until interrupt() fires, then returns control here.
43result = graph.invoke(
44    {"item": "GPU rental, 8 hours", "amount": 96.00, "status": "pending"},
45    config,
46)
47print(result["__interrupt__"])  # the payload passed to interrupt()
48
49# Second call: same thread_id, resumes request_approval with decision=True,
50# then continues on into charge_card.
51final = graph.invoke(Command(resume=True), config)
52print(final["status"])  # "charged"

Walk through what actually happens across those two calls with the mechanism above in mind. The first .invoke() runs request_approval, hits interrupt(), and that call raises internally rather than returning a value; the exception unwinds past request_approval, past the graph’s own dispatch loop, and the runtime catches it, checkpoints the paused state under thread_id="order-42", and hands back a result whose __interrupt__ key holds the exact dict that was passed in (question, item, amount). charge_card never runs. Nothing about this call blocks; the Python process is free the entire time, it’s just that the function returned early with an exception instead of a value, and the caller happens to have code that turns that into a printed prompt.

The second .invoke() is where the re-entry mechanics matter. Passing Command(resume=True) on the same config tells LangGraph to load the checkpoint for "order-42" and re-enter request_approval, and it re-enters it from the top of the function, not from the interrupt() line. That’s why the node is written the way it is: everything before interrupt() in request_approval is nothing but building the dict to show the human, so re-running it a second time is harmless, it produces the same dict and nothing else happens. This time, though, the interrupt() call doesn’t raise; it returns True, the value passed as resume, and decision is bound to it as if the call had done that from the start. The function falls through to return {"status": "approved"}, the graph’s normal edge takes it into charge_card, and that node, which never ran on the first call and only exists after the approval gate, performs the actual charge exactly once.

The pause and resume cycle, visually #

sequenceDiagram
    participant Caller
    participant Runtime as LangGraph runtime
    participant Node as request_approval

    Caller->>Runtime: invoke(input, config)
    Runtime->>Node: run node
    Node->>Runtime: interrupt(payload) unwinds
    Runtime->>Runtime: checkpoint paused state
    Runtime-->>Caller: result.__interrupt__ = payload

    Caller->>Runtime: invoke(Command(resume=True), config)
    Runtime->>Runtime: load checkpoint
    Runtime->>Node: re-run node from the top
    Node->>Node: interrupt(payload) returns True
    Node-->>Runtime: return status=approved
    Runtime->>Runtime: continue to charge_card
    Runtime-->>Caller: final state

Why re-run instead of resume mid-function #

It’s worth asking why LangGraph is built this way, because “re-run the whole node” sounds like the harder path avoided rather than chosen. Resuming a Python function mid-body, picking up right after interrupt() with all its local state intact, would mean serializing the interpreter’s own stack frame: local variables, the exact bytecode offset, any open loops or context managers. That’s roughly what a coroutine or a generator can do within a single process, but it doesn’t survive a checkpoint written to SQLite or Postgres, read back an hour later, possibly in a different process entirely. LangGraph sidesteps that whole problem by only ever checkpointing at node boundaries and treating a node’s execution as replayable from scratch. Nothing more exotic than “the function runs from line one” ever needs to be persisted, which is also exactly why a thread can be resumed on a different machine days later and it just works, no interpreter state to smuggle across the wire.

The cost of that simplicity lands squarely on you: any code before interrupt() has to be idempotent, safe to run more than once with no observable difference. In the example, that’s trivially true because request_approval does nothing but build a dict before pausing. It stops being trivial the moment someone writes a node that calls an external API, appends to a log, or increments a counter before the interrupt() line, because all of that repeats on every resume, and LangGraph has no mechanism to detect or skip the repeat. The discipline this forces, read-only and side-effect-free before the gate, actual side effects in a separate node after it, isn’t a stylistic preference, it’s the direct consequence of the re-run design and the price of not having to solve stack serialization.

That cost is also the honest answer to when a pause is worth it. Every interrupt() adds real latency (a human has to see the prompt and respond) and real complexity (a second .invoke() call, a thread_id to track, a UI or channel to surface the payload through). For an agent that reads data or takes reversible actions, that cost buys you nothing, and trusting the agent to just proceed is the better trade. It earns its cost at the specific step where the action is irreversible or expensive to undo: the charge, the delete, the message that goes out to a customer. The gate belongs exactly there, on the smallest possible node, not wrapped around an entire agent loop, both because that’s the only place the cost is worth paying and because it’s the only design that keeps the idempotency requirement cheap to satisfy.

Summary #

interrupt() doesn’t pause a thread the way a blocking call would; it unwinds one, handing control back to the caller with a payload attached, and the checkpointer from chapter 05 is what makes that unwind recoverable instead of just a dead end. Command(resume=...) re-enters on the same thread by replaying the interrupted node from its first line, which is a deliberate trade of “resume exactly mid-function” for “replay deterministically from a checkpoint,” and it’s a trade that pushes an idempotency requirement onto whatever code sits before the gate. None of that is free, so it’s worth reserving for the steps where being wrong is expensive and irreversible, not sprinkling it across every node out of general caution. The next chapter looks at a different kind of guardrail, one that doesn’t pause for anyone: structured output constrains what the model is allowed to say in the first place, instead of stopping to ask whether it should say it.