Pause an AI Agent for Human Approval with LangGraph interrupt()
How to stop an agent before a risky action, wait as long as a person needs, and resume exactly where it left off. Covers interrupt(), checkpointers, the rules that cause duplicate actions, and approval design.
By Naeem Akhtar · 13 min read
In short
- interrupt() pauses a LangGraph run, saves state through a checkpointer, and waits until you resume with Command(resume=...) on the same thread ID.
- On resume the node restarts from its first line. Any code before interrupt() runs again, so keep it idempotent or move it after.
- The graph waits indefinitely. Notifications, expiry, approver identity and audit logging are yours to build around it.
- Use a durable checkpointer in production. An in-memory one loses every pending approval when the process restarts.
What 'pause for approval' really requires
Asking a model to 'check with the user before sending' is a request, not a control. A model can forget, be talked out of it, or misjudge what counts as risky. A real approval gate sits in your code, outside the model: the run stops at a defined point, nothing downstream can happen until a person decides, and the decision is recorded.
That has three consequences. The run must be able to stop for hours or days without holding a server open. It must be able to continue later from exactly the same state. And the pause must be something the model cannot skip. LangGraph's interrupt() is built for this, and this guide shows how it behaves, which rules you must follow, and what you still have to design yourself.
The details below come from the LangGraph documentation, checked on 10 November 2026. LangGraph moves quickly, and some newer APIs, such as event streaming and typed response schemas, have version requirements, so check the docs for the version you run. To see how LangGraph compares with the OpenAI, Microsoft, CrewAI, Bedrock and Temporal approaches, read Human-in-the-Loop AI Agent Frameworks Compared. For the wider design question of where to put approvals at all, read Designing Human-in-the-Loop AI Agents for Business Workflows and Agentic Workflows: Human in the Loop or Fully Autonomous.
How interrupt() works
Calling interrupt() inside a graph node does five things, per the documentation:
- Execution suspends at exactly that point.
- The graph state is saved through the checkpointer.
- The value you passed to interrupt() is returned to whoever invoked the graph, under __interrupt__ with the default invoke call, or on the interrupts property when using event streaming.
- The graph waits indefinitely.
- When you resume, the value you supply becomes the return value of the interrupt() call inside the node.
To use it you need three things: a checkpointer that persists state, a thread ID in the run configuration that tells LangGraph which saved state to load, and an interrupt() call whose payload is JSON-serializable. The thread ID is effectively your cursor. Reusing it resumes the same checkpoint, and a new value starts a fresh run with empty state.
Unlike static breakpoints, which pause before or after named nodes, interrupt() is dynamic. You can place it anywhere in your code and make it conditional, for example pausing only when a refund exceeds a limit. LangGraph's own documentation says static breakpoints are meant for debugging and are not recommended for human-in-the-loop workflows.
A minimal approval gate
This example gates a refund. The approval node pauses with the details a reviewer needs and routes to one of two nodes based on the answer. It is adapted from the pattern in the LangGraph documentation, with a business scenario added.
from typing import Literal, TypedDict
from langgraph.checkpoint.memory import InMemorySaver # use a durable saver in production
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
class RefundState(TypedDict):
order_id: str
amount: float
reason: str
status: str
def request_approval(state: RefundState) -> Command[Literal["issue_refund", "decline"]]:
decision = interrupt({
"question": "Approve this refund?",
"order_id": state["order_id"],
"amount": state["amount"],
"reason": state["reason"],
})
return Command(goto="issue_refund" if decision["approved"] else "decline")
def issue_refund(state: RefundState):
# The side effect lives AFTER the approval, in its own node
return {"status": "refunded"}
def decline(state: RefundState):
return {"status": "declined"}
builder = StateGraph(RefundState)
builder.add_node("request_approval", request_approval)
builder.add_node("issue_refund", issue_refund)
builder.add_node("decline", decline)
builder.add_edge(START, "request_approval")
builder.add_edge("issue_refund", END)
builder.add_edge("decline", END)
graph = builder.compile(checkpointer=InMemorySaver())The refund itself sits in a separate node after the gate. That is deliberate and it matters for a reason covered below: the node containing interrupt() runs again on resume, so you do not want the irreversible action in the same node.
Pausing and resuming from your application
from langgraph.types import Command
config = {"configurable": {"thread_id": "refund-order-8841"}}
# First run: reaches interrupt() and pauses
result = graph.invoke(
{"order_id": "8841", "amount": 240.0, "reason": "Damaged on arrival", "status": "pending"},
config,
)
pending = result["__interrupt__"][0].value # the payload for your approval UI
# ...hours later, a reviewer decides. Resume on the SAME thread_id:
final = graph.invoke(Command(resume={"approved": True}), config)
print(final["status"]) # refundedThree details from the documentation are easy to get wrong. You must use the same thread ID on resume. The value in Command(resume=...) becomes what interrupt() returns, and it can be any JSON-serializable value. And Command(resume=...) is the only Command pattern meant to be passed as input to invoke or stream. The update, goto and graph options are for returning from nodes, so do not use them to continue a conversation.
Store the thread ID alongside your own business record, such as the refund request or ticket, so that when a person clicks Approve in your interface you can find the right paused run.
The rule that causes most bugs: the node restarts
When you resume, LangGraph does not continue from the line after interrupt(). It restarts the whole node from the beginning, and interrupt() then returns the value you supplied. Everything above the interrupt() call in that node runs a second time.
That is harmless for reading data. It is dangerous for anything with a side effect. Suppose a node creates an audit record, then calls interrupt(), then continues. Each resume creates another audit record. If it sends a message, the message goes out again. This is the most common way an approval step ends up causing the duplicate action it was meant to prevent.
| Rule from the LangGraph docs | Why it matters | What to do |
|---|---|---|
| Side effects before interrupt() must be idempotent | The node re-runs on resume, so earlier code executes again | Use upserts, check for existing records, or move the side effect after the interrupt or into a separate node |
| Do not wrap interrupt() in a bare try/except | interrupt() pauses by raising a special exception, and a bare except swallows it | Keep interrupt() apart from error-prone code, and catch only specific exception types |
| Do not reorder or conditionally skip interrupt() calls in a node | Resume values are matched by index, in order | Call them in the same order every time |
| Do not loop interrupt() with while True | Each resume replays every earlier iteration | Call interrupt() once per node run and use a conditional edge to loop back |
| Pass only JSON-serializable values | Checkpointers must store the payload | Send simple dictionaries, not functions or class instances |
| Static interrupt_before and interrupt_after are for debugging | They pause at nodes, not at decisions | Use interrupt() for real approvals |
Approval does not remove the need for care afterwards. Even after approval, retries can repeat a side effect if a node fails after committing it, so the documentation recommends idempotent operations or deduplicating writes in the external system. For payments and similar actions, pass an idempotency key derived from the request, so a second attempt is recognised and ignored.
Approve, edit or reject: interrupting inside a tool
A plain yes or no is often not enough. Reviewers frequently want to fix a draft or change an amount rather than reject the whole action. LangGraph supports this by letting the resume value carry edits, and you can put the interrupt directly inside a tool so approval travels with the tool wherever it is used.
from langchain.tools import tool
from langgraph.types import interrupt
@tool
def send_customer_email(to: str, subject: str, body: str):
"""Send an email to a customer."""
response = interrupt({
"action": "send_customer_email",
"to": to,
"subject": subject,
"body": body,
"message": "Approve sending this email?",
})
if response.get("action") == "approve":
# The reviewer may have edited any field
final_to = response.get("to", to)
final_subject = response.get("subject", subject)
final_body = response.get("body", body)
return deliver(final_to, final_subject, final_body) # your sending code
return "Email cancelled by reviewer"Resume with {"action": "approve"} to send as drafted, with extra keys to send an edited version, or with any other action to cancel. The model sees the tool result and carries on, so a rejection is information it can use, for example by offering the customer a different answer.
Notice that the sending code runs only after the interrupt returns. That respects the restart rule, because nothing irreversible happens before the pause.
Designing the approval request
The payload you pass to interrupt() is what the reviewer sees, so it decides whether approvals are quick and correct or slow and rubber-stamped. This part is design judgement from our own builds rather than anything LangGraph prescribes.
| Include | Why |
|---|---|
| The exact action and its parameters | The reviewer should approve a specific thing, not a vague intention |
| Why the agent wants to do it | A one or two line reason from the run's own context |
| The evidence it used | Links or excerpts, so the reviewer can check rather than trust |
| Risk and reversibility | Amount, recipient count, whether it can be undone |
| Who and what it affects | Customer, account or record identifiers |
| Options available | Approve, edit, reject, and escalate if you support it |
| A request ID and an expiry | For audit, and so stale requests do not sit forever |
Resist the urge to send everything. A reviewer who has to read a page to approve a small change will approve without reading. Show the decision and the evidence, and link to the rest.
If you use LangGraph 1.2.12 or later, the interrupt() call also accepts a response_schema, a Pydantic model, TypedDict or dataclass describing the answer expected on resume. The resume value is then validated, and invalid input raises a validation error you can retry with the same thread ID. It is a clean way to stop a reviewer interface from sending malformed decisions.
What LangGraph does not do for you
The documentation is clear that the graph waits indefinitely and that interrupt payloads are returned to the caller. It does not mention notification, deadlines, approver identity or audit trails, and we found no built-in feature for them in the pages we read. That is our reading of those pages, so check the current documentation, but plan on building these yourself:
- Notification: something must tell a person that a request is waiting, whether a message, an email, a task in a queue or a screen in your own tool.
- Expiry and escalation: decide what happens if nobody answers in four hours. Common answers are reminding, escalating to someone else, or cancelling safely.
- Approver identity and permission: check that the person resuming the run is allowed to approve this action and amount, and record who they are.
- Audit: store the request, the payload shown, the decision, who made it and when, in your own system of record, not only in checkpoints.
- Concurrency: if two people click Approve at once, make sure only one resume goes through, for example by tracking request status in your own database.
A tidy structure is to keep an approval request table in your database, with the thread ID, status, payload, deadline and decision. Your interface reads and writes that table. A small service resumes the graph when a decision lands. The graph itself stays simple.
Persistence: the part that decides whether approvals survive
Every pending approval is a checkpoint. If the checkpoint store is lost, the approval is lost, and the run cannot be resumed. For durability modes, replay traps, encryption and retention, see AI Agent Checkpointing Explained.
| Checkpointer | Behaviour | Use for |
|---|---|---|
| InMemorySaver or MemorySaver | Stores checkpoints in RAM, so everything is lost when the process restarts | Tests and demos only |
| SqliteSaver | Local file-based storage | Development and small single-node tools |
| PostgresSaver | PostgreSQL, with async support | Production |
| Agent Server | Handles persistence for you | If you deploy on LangChain's server |
Two practical notes from the persistence documentation. With the Postgres saver, thread IDs are stored in a limited-length column, so keep them under 255 characters and use a UUID or a hash if you need a deterministic one. And checkpoints accumulate over time, which increases latency and storage cost, so set a retention policy or a cleanup job. Decide up front how long a paused approval may live, and delete the checkpoints after that.
When several approvals are pending at once
If parallel branches of a graph each call interrupt(), the run can pause with more than one pending interrupt. Each has an ID. The documentation shows resuming them in a single call by passing a dictionary that maps each interrupt ID to its answer. Use this when one request genuinely needs several independent decisions, such as legal and finance both signing off. Keep the mapping in your approval table so you know which decision belongs to which interrupt.
Testing an approval flow
- Run to the pause and assert the payload contains what a reviewer needs and nothing secret.
- Resume with approve, reject and edit, and assert each path ends in the right state.
- Kill and restart the process while paused, using a durable checkpointer, then resume and confirm it still works.
- Resume twice with the same decision and confirm the side effect happens once.
- Put a counter in any code placed before interrupt() during testing, to confirm you understand how often it runs.
- Let a request expire and confirm the fallback behaves safely.
These tests are cheap and they catch exactly the failures that cost money: lost approvals, duplicate actions and requests nobody ever answered.
A production checklist
- A durable checkpointer is configured, with a retention policy.
- Thread IDs are unique, under 255 characters, and stored with your business record.
- Nothing irreversible happens before interrupt() in the same node.
- interrupt() is not inside a bare try/except or a loop.
- The reviewer sees the action, the reason, the evidence and the risk.
- Approvers are authenticated, and permitted for the action and amount.
- Requests have an owner, a deadline and an escalation path.
- Every request and decision is written to your own audit store.
- Actions after approval use idempotency keys.
For the wider build, see Building Production-Ready AI Agents with LangGraph and How to Build Human-in-the-Loop AI Agents That Teams Actually Trust. If you want approval steps designed into an agent for your own workflow, see our agentic workflow orchestration service.
Frequently asked questions
What does interrupt() do in LangGraph?
It pauses graph execution at the point it is called, saves the state through the checkpointer, returns your payload to the caller, and waits indefinitely. When you resume with Command(resume=...) on the same thread ID, the supplied value becomes the return value of interrupt() and the run continues.
Why did my action run twice after a human approved it?
On resume, LangGraph restarts the whole node from the beginning, so any code before interrupt() runs again. If an irreversible action sits before the interrupt in the same node, it repeats. Move it after the interrupt or into a separate node, and use idempotent operations or idempotency keys.
Do I need a checkpointer to use interrupt()?
Yes. The documentation lists a checkpointer and a thread ID as requirements. Use an in-memory saver only for tests, because it loses all pending approvals when the process restarts. Use a durable one such as the Postgres saver in production.
How long can a LangGraph run wait for approval?
The documentation says the graph waits indefinitely until resumed, so the limit is how long you keep the checkpoint. Build your own deadline and escalation, and a retention policy that deletes old checkpoints, because LangGraph does not expire a request on its own.
Can a reviewer edit the agent's action instead of just approving it?
Yes. The resume value can be any JSON-serializable value, so you can return edited fields such as a new subject or amount, and the node or tool uses them in place of the original. Validate the edits, and log both the original proposal and the final version.
Should I use interrupt_before instead of interrupt()?
For real approvals, no. The LangGraph documentation recommends interrupt() for human-in-the-loop workflows and describes interrupt_before and interrupt_after as debugging breakpoints. interrupt() can be conditional and can sit inside a node or a tool, which suits decisions like 'only ask above this amount'.
Need help with Agentic AI Development? Talk to the engineers.
Tell us what you want to build. You'll get a straight answer on scope, approach and cost from the people who do the work.
Keep reading
Related guides
Agentic AI
Human-in-the-Loop AI Agent Frameworks Compared: LangGraph, CrewAI, OpenAI and More
Every serious agent framework can pause for a human decision, but they do it differently. This guide compares how six of them pause, store state, resume and handle risk, and what each leaves for you to build.
14 min read
Agentic AI
Human Approval Checkpoints for AI Agents in Regulated Finance
What FINRA, SR 11-7, the EU AI Act and GDPR actually say about human oversight, and how to turn it into engineering: risk-tiered checkpoints, reviewers who really review, and an evidence trail. Not legal advice.
13 min read
Agentic AI
AI Agent Checkpointing Explained: Durable State, Replay and Recovery
How checkpointing lets an agent survive crashes, wait for people and be replayed for debugging, using LangGraph as the worked example. Covers durability modes, resuming, replay traps, storage, encryption and retention.
13 min read