LangGraph Human-in-the-Loop (2026): Pause an Agent for Approval with interrupt()
A runnable LangGraph human-in-the-loop tutorial for 2026: pause an agent mid-run with interrupt(), review the proposed action, then approve, edit, or reject it with Command(resume=...). Includes the checkpointer requirement and the re-run-from-the-top gotcha that breaks most tutorials.
On this page
An AI agent that can send emails, move money, or delete records should not run unsupervised on day one. You want a checkpoint: the agent proposes, a human approves, and only then does the action fire. In LangGraph (2026), that checkpoint is one function call: interrupt().
This is a runnable, framework-honest walkthrough of the modern human-in-the-loop pattern. If you have not built a basic graph yet, start with the LangGraph graph-agent tutorial and come back. Everything here builds on a plain StateGraph.
Quick answer (2026)
To add a human-in-the-loop to a LangGraph agent, call interrupt(payload) inside a node. The graph pauses, saves its state through a checkpointer, and returns control to you. You inspect the payload, then resume with graph.invoke(Command(resume=value), config). The value you pass becomes the return value of interrupt(). Two hard requirements: you must compile the graph with a checkpointer and pass a thread_id, and any side effect placed before interrupt() will run again on resume, so keep it idempotent.
What you need
pip install langgraph
This tutorial targets the LangGraph 1.x line (current in 2026). The only imports that matter for human-in-the-loop are the interrupt and Command primitives and a checkpointer:
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver
InMemorySaver keeps state in process, which is all you need to learn the pattern. In production you swap it for a persistent saver (Postgres, SQLite) so a pause can outlive a restart.
The scenario: approve a tool call before it runs
We will build a tiny agent whose job is to send an email. Between "the agent decided to send this email" and "the email is actually sent" we insert a human gate. The reviewer sees the drafted email and can approve it, edit it, or reject it.
To keep the example runnable with no API key, the drafting step is deterministic. In a real build this is exactly where a model (for example Anthropic claude-sonnet-5, 2026) would produce the proposed action. The gate mechanics do not change.
1. State and the draft node
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):
request: str # what the user asked for
draft: dict # the proposed email {to, subject, body}
result: str # what finally happened
def draft_email(state: State) -> dict:
# In a real agent, a model writes this. Kept deterministic so the demo runs offline.
draft = {
"to": "customer@example.com",
"subject": "Your refund has been approved",
"body": f"Hi, regarding your request: {state['request']}. We have approved it.",
}
return {"draft": draft}
2. The human gate
This node is the whole point. It calls interrupt() with a structured payload describing what needs a decision, and it uses whatever comes back:
def human_gate(state: State) -> dict:
decision = interrupt({
"action": "send_email",
"draft": state["draft"],
"instructions": "Reply with approve / edit / reject.",
})
if decision["type"] == "approve":
return {"draft": state["draft"]}
if decision["type"] == "edit":
return {"draft": decision["draft"]} # reviewer handed back a corrected draft
# reject
return {"draft": {}, "result": f"Cancelled: {decision.get('reason', 'no reason given')}"}
interrupt(...) returns the value you later pass to Command(resume=...). Here we expect a small dict describing the decision.
3. The side-effect node (runs only after the human)
def send_email(state: State) -> dict:
if not state["draft"]:
return {} # rejected: nothing to send
d = state["draft"]
# The real, irreversible action lives HERE, after the gate.
return {"result": f"Sent to {d['to']}: {d['subject']}"}
Notice the actual send lives in its own node, after human_gate. That is deliberate, and the next section explains why.
4. Wire it up with a checkpointer
builder = StateGraph(State)
builder.add_node("draft_email", draft_email)
builder.add_node("human_gate", human_gate)
builder.add_node("send_email", send_email)
builder.add_edge(START, "draft_email")
builder.add_edge("draft_email", "human_gate")
builder.add_edge("human_gate", "send_email")
builder.add_edge("send_email", END)
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
If you forget checkpointer=, interrupt() raises. The pause has nowhere to be saved.
Running it: pause, inspect, resume
Start the run with a thread_id. The graph executes until it hits interrupt(), then returns:
config = {"configurable": {"thread_id": "email-1"}}
event = graph.invoke({"request": "refund for order 4417"}, config=config)
print(event["__interrupt__"])
The paused run surfaces under the __interrupt__ key. Inside it is the exact payload you passed to interrupt(), so your UI (a CLI prompt, a Slack message, a web form) has everything it needs to show the human what is about to happen.
Approve
final = graph.invoke(Command(resume={"type": "approve"}), config=config)
print(final["result"])
# Sent to customer@example.com: Your refund has been approved
Same config, same thread_id. The resume value becomes the return value of interrupt(), human_gate finishes, and the graph rolls on to send_email.
Edit
fixed = {
"to": "customer@example.com",
"subject": "Refund approved (order 4417)",
"body": "Hi, your refund for order 4417 is approved and will post in 3 to 5 days.",
}
graph.invoke(Command(resume={"type": "edit", "draft": fixed}), config=config)
Reject
graph.invoke(Command(resume={"type": "reject", "reason": "wrong customer"}), config=config)
Three canonical human actions, one resume call each. That is the entire control surface.
The gotcha that breaks most tutorials: the node re-runs from the top
Here is the detail almost every older guide skips. When you resume, LangGraph re-runs the entire interrupted node from its first line. It does not continue from the exact statement after interrupt(). Any code above interrupt() executes a second time.
So this is a trap:
def human_gate_BAD(state: State) -> dict:
charge_credit_card(state["draft"]) # BUG: runs again on resume -> double charge
decision = interrupt({"draft": state["draft"]})
...
On approve, charge_credit_card fires twice: once on the first pass, once when the node replays after resume. The fix is the structure we used above: keep the gate node free of side effects and put the irreversible action in a later node (send_email) that only runs after the human decision flows through. If you must do work before the interrupt, make it idempotent (safe to repeat).
This single rule, side effects after the gate, is the difference between a demo and something you can point at real customers.
Why interrupt() replaced interrupt_before breakpoints
If you have read tutorials from 2024, you saw graphs paused with static breakpoints at compile time:
# older style
graph = builder.compile(checkpointer=cp, interrupt_before=["human_gate"])
That still exists, but it is coarse: the pause point is fixed and it cannot carry a payload. The dynamic interrupt() function is the current recommendation because you decide at runtime whether to pause (pause only for high-value emails, say) and you hand the reviewer a structured description of the decision. For a human-in-the-loop that a person actually uses, that payload matters.
Where to take it next
The pattern generalizes cleanly. Any point where an agent is about to do something you cannot undo is a candidate for a gate: publishing content, running SQL, spending a budget, messaging a customer. Pair this with clear tool boundaries (see how to make an AI agent for the anatomy and guardrails), and the human stays in control of exactly the steps that warrant it, and nothing more.
For the full API surface (multiple interrupts in one graph, resuming several at once, streaming the paused state), the official LangGraph docs are the source of truth, and the LangGraph source on GitHub is worth reading when behavior surprises you.
Sources
- LangGraph interrupts documentation, LangChain, 2026: https://docs.langchain.com/oss/python/langgraph/interrupts
- LangGraph source (MIT), GitHub, 2026: https://github.com/langchain-ai/langgraph
- Anthropic model overview, 2026: https://docs.anthropic.com/en/docs/about-claude/models
Written by
Ren OkabeFrequently asked questions
What is human-in-the-loop in LangGraph?
It is a pattern where a LangGraph agent pauses before a consequential step so a person can review and steer it. In 2026 you implement it by calling interrupt() inside a node. The graph stops, saves its state through a checkpointer, and hands control back to your code. You resume with graph.invoke(Command(resume=value), config), and whatever you pass as the resume value becomes the return value of interrupt().
Do I need a checkpointer for interrupt() to work?
Yes. interrupt() pauses execution and the graph must persist its state somewhere so it can pick up later, even in the same process. Compile the graph with a checkpointer (InMemorySaver for local work, a database saver for production) and pass a thread_id in the config. Without a checkpointer the pause cannot be saved and resume will not work.
How do I resume a LangGraph agent after an interrupt?
Call the graph again with a Command object: graph.invoke(Command(resume=your_value), config). Use the same config with the same thread_id you started with. The value you pass becomes the return value of the interrupt() call that paused the node, so you can send back an approval boolean, an edited payload, or a rejection reason.
Why does my code before interrupt() run twice?
Because on resume LangGraph re-runs the whole node from its first line, not from the exact point where interrupt() was called. Any side effect placed before interrupt() (writing a row, sending a request, charging a card) executes again. Keep everything before interrupt() idempotent, or move the side effect into a separate node that runs after the human decision.
What replaced the old interrupt_before breakpoints?
Older LangGraph tutorials paused the graph by passing interrupt_before=["node"] at compile time (static breakpoints). The current approach is the dynamic interrupt() function called from inside a node. It is more flexible: you decide at runtime whether to pause, and you can hand the reviewer a structured payload describing exactly what needs approval.
Can I use human-in-the-loop with a real LLM agent?
Yes. The interrupt happens between the model proposing an action and your code executing it. Have the model (for example Anthropic claude-sonnet-5 in 2026) produce a proposed tool call, interrupt() to show that proposal to a human, then only run the tool after approval. The example in this tutorial keeps the proposal step deterministic so it runs without an API key, but the gate works identically once you wire in a model.
Related tutorials
LangGraph Tutorial: Build a Graph Agent in Python (2026)
A hands-on LangGraph tutorial for 2026: build the same tool-using AI agent two ways, with the five-line create_agent helper and as an explicit StateGraph you can customize. Fully runnable Python.
How to Make an AI Agent in 2026 (Step by Step)
The five parts every AI agent has, a runnable Python example in about 40 lines, and an honest guide to the guardrails and framework choices most tutorials skip.
How to Build an MCP Server in Python with FastMCP (2026)
A runnable 2026 tutorial: build a write-capable MCP server in Python with FastMCP and the official MCP SDK. Add tools with @mcp.tool(), back them with SQLite, test in the Inspector, and connect it to Claude for Desktop.

