TechByteByByte

Interrupts: Pausing a Graph for Human Approval

Module 1's refund-approval scenario, finally, properly resolved. Genuinely pause execution mid-run, persist exactly where you stopped, and resume — not a simulation, the real mechanism.

#LangGraph#Interrupts#Human-in-the-Loop

Recall Module 1’s opening scenario, word for word: “For refunds over $100, you must get human approval before executing — pause and wait.” Recall the honest verdict this course delivered back then: a system prompt asking for this was a request, not a guarantee. This module is where that gap finally, completely closes.

interrupt(), the real mechanism

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver

class State(TypedDict):
    refund_amount: float
    approved: bool

def request_approval(state: State) -> dict:
    decision = interrupt({"question": f"Approve a ${state['refund_amount']} refund?"})
    return {"approved": decision}

def process_refund(state: State) -> dict:
    return {"approved": state["approved"]}

builder = StateGraph(State)
builder.add_node("request_approval", request_approval)
builder.add_node("process_refund", process_refund)
builder.add_edge(START, "request_approval")
builder.add_edge("request_approval", "process_refund")
builder.add_edge("process_refund", END)

graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "refund-request-1"}}

result = graph.invoke({"refund_amount": 250.0, "approved": False}, config=config)
print(result)

Run this. graph.invoke(...) genuinely doesn’t run to completion — it stops, returns immediately, and result contains something new: an __interrupt__ entry, carrying the exact question request_approval raised. The graph isn’t broken or crashed. It’s genuinely, deliberately paused, with its state safely checkpointed exactly where it stopped.

What’s actually happening while paused

state_snapshot = graph.get_state(config)
print("Next node waiting to run:", state_snapshot.next)
print("Interrupt payload:", state_snapshot.tasks[0].interrupts)

This is real, not simulated. get_state shows request_approval genuinely hasn’t finished — interrupt() stopped execution mid-node, and the checkpointer, from Module 16, preserved this exact moment. This could genuinely sit here for a minute, an hour, or — with a real, persistent checkpointer instead of InMemorySaver — days, waiting for a real human.

Resuming — the real second half

result = graph.invoke(Command(resume=True), config=config)
print(result)

Command(resume=True) — recall Command from Module 9, now doing genuinely new work — hands a real value back into the exact point interrupt() paused at. decision inside request_approval becomes True, and execution continues from precisely there, not from START. This is the real, complete, honest mechanism Module 1 asked for and never had.

flowchart TD
    A[Graph reaches interrupt] --> B["Execution genuinely pauses.\nState checkpointed exactly here."]
    B --> C["Real time passes —\nseconds, hours, days"]
    C --> D["Command(resume=value)\nsent"]
    D --> E["Execution continues from\nEXACTLY where it paused"]

Resolving Module 1’s exact original scenario

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver

class State(TypedDict):
    refund_amount: float
    outcome: str

def check_and_process(state: State) -> dict:
    if state["refund_amount"] > 100:
        approved = interrupt({"question": f"Approve ${state['refund_amount']} refund?"})
        if not approved:
            return {"outcome": "Refund denied by reviewer."}
    return {"outcome": f"Refund of ${state['refund_amount']} processed."}

builder = StateGraph(State)
builder.add_node("check_and_process", check_and_process)
builder.add_edge(START, "check_and_process")
builder.add_edge("check_and_process", END)

graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "real-refund-case"}}

result = graph.invoke({"refund_amount": 250.0, "outcome": ""}, config=config)
print("Paused:", "__interrupt__" in result)

result = graph.invoke(Command(resume=True), config=config)
print(result["outcome"])

Read this against Module 1’s original system prompt one more time: “For refunds over $100, you must get human approval before executing — pause and wait.” This is genuinely, structurally that exact behavior, guaranteed by real code, not requested through a sentence a model might or might not honor.

It’s worth being direct about something beyond good practice: human oversight for high-risk automated actions is now a real, binding legal requirement in a growing number of jurisdictions. Article 14 of the EU AI Act specifically mandates human oversight for high-risk AI systems, and comparable expectations run through frameworks like the U.S. NIST AI Risk Management Framework. A real, documented 2026 industry finding put this in concrete terms: only about a third of enterprises currently meet their own internal governance bar for autonomous agents, with security and oversight gaps cited as the leading barrier to scaling agentic systems further. interrupt() isn’t solving an abstract engineering puzzle — for any application touching regulated data, financial actions, or consequential decisions, it’s solving a genuine compliance requirement that real organizations are currently being measured against.

Common mistakes worth avoiding

Interrupting without a checkpointer configured. Recall Module 16 — interrupt() genuinely depends on a checkpointer to preserve the paused state. Without one, there’s nothing durable to resume from at all.

Assuming invoke() after an interrupt automatically means the workflow finished. Recall this module’s own two-call pattern — the first invoke() genuinely returns early, paused. Checking for "__interrupt__" in the result is how your real application code knows to wait for a human, rather than treating a paused result as a completed one.

Forgetting the exact same thread_id when resuming. Command(resume=...) only works against the specific thread that’s actually paused — a mismatched thread_id finds no interrupted execution to resume at all.

What you should take away from this module

  • interrupt() genuinely pauses execution mid-node, checkpointing exactly where it stopped — this is real, not a simulated wait.
  • graph.invoke() against a paused thread returns immediately with an "__interrupt__" entry — your application code checks for this to know a human decision is genuinely needed.
  • Command(resume=value) continues execution from precisely the paused point, with the human’s real decision available inside the node that requested it.
  • This is the complete, honest resolution to the exact scenario Module 1 opened this entire course with.

Where this goes next

The next module covers the real, practical patterns built on top of this mechanism — approve, reject, modify, and escalate — the genuine range of human-in-the-loop behavior real applications need beyond a simple yes-or-no.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed