LangGraph Deep Agents Human-in-the-Loop: interrupt_on
LangGraph deep agents human-in-the-loop is one parameter: interrupt_on. You name the tools that need a person's sign-off, the harness pauses before those calls run, a reviewer approves, edits or rejects each one, and the run picks up from its checkpoint. The mechanics take ten minutes to learn. The harder question is which tools to gate. My thesis for this part: approval belongs on the irreversible tools only. Interrupt everything and humans stop reading, and a rubber-stamped approval is worse than none, because it looks like oversight.
LangGraph Deep Agents, a 10-part series. ← Part 8 · Part 10 →
This is part 9 of the series. Part 5 covered sandboxes and permissions, which decide what the agent can do. Human-in-the-loop decides what it may do without asking.
How does human-in-the-loop work in LangGraph deep agents?
Per the Deep Agents human-in-the-loop docs, setting interrupt_on adds HumanInTheLoopMiddleware to the agent's middleware stack. When the model proposes a call to a listed tool, the middleware stops before the tool runs and raises a LangGraph interrupt. It does this with the same primitive described in the LangGraph interrupts docs: state is written to the checkpointer, control returns to your code, and a later call with Command(resume=...) continues from that exact point.
That gives you two hard requirements:
- A checkpointer. Without one the paused state has nowhere to live, and there is nothing to resume.
- A stable
thread_id. The resume call has to use the same config as the call that paused, or it starts a fresh thread.
The same stack also includes PatchToolCallsMiddleware, which repairs the message history if a run is cancelled or interrupted before a tool returns. A run that was abandoned mid-approval won't leave a dangling tool call that breaks the next model turn.
How do you configure interrupt_on for a deep agent?
interrupt_on maps tool names to one of three values:
True: interrupt, with all decisions allowed (approve, edit, reject, respond).False: never interrupt for this tool.- A config dict: set
allowed_decisionsto limit what the reviewer can do. In Python you can also add awhenpredicate (requireslangchain>=1.3.3).
This is the docs example, trimmed to one provider; the tool names remove_file and the file_path argument come from it:
from langchain.tools import tool
from deepagents import create_deep_agent
from langgraph.checkpoint.memory import MemorySaver
@tool
def remove_file(path: str) -> str:
"""Delete a file from the filesystem."""
return f"Deleted {path}"
@tool
def fetch_file(path: str) -> str:
"""Read a file from the filesystem."""
return f"Contents of {path}"
@tool
def notify_email(to: str, subject: str, body: str) -> str:
"""Send an email."""
return f"Sent email to {to}"
# Checkpointer is REQUIRED for human-in-the-loop
checkpointer = MemorySaver()
agent = create_deep_agent(
model="anthropic:claude-sonnet-5",
tools=[remove_file, fetch_file, notify_email],
interrupt_on={
"remove_file": True, # Default: approve, edit, reject, respond
"fetch_file": False, # No interrupts needed
"notify_email": {"allowed_decisions": ["approve", "reject"]}, # No editing
},
checkpointer=checkpointer, # Required!
)
The three entries show the three risk tiers. Reading is free. Deleting is gated and fully editable. Email is gated, but editing is turned off, so the reviewer can send the message the agent wrote or refuse it, and can't quietly rewrite it.
Conditional interrupts with when
Often a tool is only risky for some arguments. The when predicate receives a ToolCallRequest and returns True to pause or False to auto-approve. The docs example pauses only writes that land outside /workspace/:
from langchain.agents.middleware import ToolCallRequest
def writes_outside_workspace(request: ToolCallRequest) -> bool:
"""Pause writes to paths outside the workspace directory."""
path = request.tool_call["args"].get("file_path", "")
return not path.startswith("/workspace/")
interrupt_on = {
"write_file": {
"allowed_decisions": ["approve", "edit", "reject"],
"when": writes_outside_workspace,
},
}
Calls where the predicate returns False never enter the interrupt batch, so the reviewer sees only the calls that need a decision. That is the main way to keep approvals rare enough that people still read them.
What do approve, edit, reject and respond actually do?
The docs define four decision types:
- approve runs the tool with the agent's original arguments.
- edit runs it with modified arguments. You pass an
edited_actionthat has the toolnameand the newargs. - reject skips the call and sends feedback to the agent as the tool result.
- respond returns the human's message as a synthetic tool result. It exists for "ask user" style tools, where the human is the tool.
Two warnings from the docs (repeated in the LangChain human-in-the-loop guide) are worth repeating. First, never use respond to deny a side-effecting tool: the model may read the message as a successful result. Use reject for that. Second, keep edits small. If you change the arguments a lot, the model may reconsider its plan and call the tool again or do something you didn't expect. If the change you want is large, reject with a clear message and let the agent propose again.
On rejection messages: when you omit message, the default tells the model the tool did not run and not to retry it unless the user asks. For anything that matters, say what should happen next. "Do not retry deletion. Ask which file to archive instead." gives the agent a next step instead of a dead end.
How do you resume a paused deep agent with Command?
When the run pauses, the result carries an __interrupt__ key. Its value holds action_requests (tool name and args for each pending call) and review_configs (which decisions each tool allows). You answer with one decision per action request, in the same order, and invoke again on the same thread:
from langchain_core.utils.uuid import uuid7
from langgraph.types import Command
config = {"configurable": {"thread_id": str(uuid7())}}
result = agent.invoke(
{"messages": [{"role": "user", "content": "Delete the file temp.txt"}]},
config=config,
)
if result.get("__interrupt__"):
interrupt_value = result["__interrupt__"][0].value
action_requests = interrupt_value["action_requests"]
decisions = [
{
"type": "reject",
"message": "User rejected deleting temp.txt. Do not retry deletion.",
}
]
result = agent.invoke(
Command(resume={"decisions": decisions}),
config=config, # Must use the same config!
)
print(result["messages"][-1].content)
If the model proposes several gated calls in one step, say a delete and an email, they come back as one interrupt with two action requests. You can approve one and reject the other in the same resume. The ordering rule is strict, so build your UI on the action_requests list rather than on tool names.
Because the pause lives in the checkpointer and not in process memory, the reviewer can answer minutes or days later. MemorySaver is fine for a notebook. Anything a human reviews asynchronously needs a durable checkpointer, which the LangGraph persistence docs recommend for production. That is a production concern, covered in part 10.
How do you stream decisions and handle subagent interrupts?
For a live UI, use LangGraph streaming: agent.stream(..., stream_mode=["messages", "updates"]). The messages mode streams tokens, and the updates mode delivers the __interrupt__ payload. When the stream ends with an interrupt pending, collect decisions and stream again with Command(resume={"decisions": [...]}) as the input. Repeat until a stream finishes with no interrupt. The docs note that this flat decisions payload is right for a normal interrupt_on pause. Only when several interrupts are pending at once (parallel branches) do you map interrupt IDs to resume values.
Subagents can have their own interrupt_on (see the Deep Agents subagents docs), which overrides the parent's for that subagent. A file-manager subagent might gate reads that the main agent runs freely. Their interrupts show up as __interrupt__ in the parent's updates stream without subgraphs=True, and you resume them the same way. Tools can also call interrupt() directly when the approval logic doesn't fit a per-tool rule.
Which tools deserve approval?
The mechanism makes gating any tool cheap, and that's the danger. Each interrupt costs a person's attention. Once most interrupts are approvals of harmless reads, reviewers learn to click approve without looking, and the one destructive call gets through with them. Choose by the cost of being wrong:
- Gate: irreversible or outward-facing actions. Deleting data, sending email or messages, payments, deploys, writes to shared systems. Leave
editoff when the reviewer shouldn't author content (the email case), and leave it on when fixing a parameter is the common correction (a wrong path or recipient). - Gate conditionally: tools that are only risky for some arguments. Use
whenfor writes outside a workspace, amounts above a threshold, recipients outside a domain. - Don't gate: reads, searches, planning, scratch files. These are reversible, and the virtual filesystem and the sandbox already contain them. If a read is dangerous, the fix belongs in permissions, not in approvals.
A useful check: if your reviewers approve nearly everything for a given tool, that tool probably shouldn't be gated, or it needs a when predicate.
FAQ
Does interrupt_on work without a checkpointer?
No. The docs mark the checkpointer as required. The interrupt saves state so the run can resume, and the resume call must use the same thread_id.
What happens if the agent calls two approval-required tools at once?
They are batched into one interrupt. You return one decision per entry in action_requests, in the same order. Mixing approve and reject in a single resume is fine.
Should I use reject or respond to deny an action?
Use reject. respond is for tools where the human supplies the answer, such as ask_user. The model may treat a respond message as a successful tool result.
Series: 01 Harness · 02 Quickstart · 03 MCP tools · 04 Filesystem · 05 Sandbox · 06 Skills and memory · 07 Context · 08 Subagents · 09 Human-in-the-loop · 10 Production
