How to add human approval to AI agents in LangChain (per-tool-call interrupts tutorial)
A step-by-step tutorial for LangChain's HumanInTheLoopMiddleware: pause an agent before risky tool calls, approve, edit or reject each call individually with the new per-call interrupt mode, add conditional approval rules and resume safely.
Quick answer: In LangChain, add HumanInTheLoopMiddleware to your agent, list which tools need approval in interrupt_on, add a checkpointer, and set interrupt_mode="per_call" (langchain 1.4.6+) so each risky tool call pauses separately. You then approve, edit or reject each call by its interrupt ID and resume the agent.
Giving an AI agent tools is powerful, and risky. Sending emails, deleting records, moving money or deploying code should pause for a human. This tutorial shows how to build that properly with LangChain, from the basic idea to production details.
If you want the theory first, read human-in-the-loop for AI agents: approval patterns that scale.
How human-in-the-loop works in LangChain
- The model decides to call a tool, for example
send_email. - Middleware intercepts the call before it runs and raises an interrupt.
- The agent's state is saved by a checkpointer, so nothing is lost while it waits.
- A human reviews the call and returns a decision: approve, edit or reject.
- The agent resumes from exactly where it paused.
Batched vs per-call interrupts
| Mode | How it pauses | You answer with |
|---|---|---|
| Batched (default) | Once per model turn, bundling every gated tool call | An ordered list of decisions |
Per-call ("per_call") |
Once for each gated tool call | One decision per interrupt, keyed by its ID |
Per-call is easier to build interfaces for (one approval card per action) and lets different people approve different actions. LangChain's changelog says it plans to make per-call the default in the next major release.
Step 1: install
pip install -U "langchain>=1.4.6" langgraphPer-call mode needs langchain 1.4.6 or newer; conditional rules (the when option, below) need 1.3.3 or newer.
Step 2: define tools
from langchain.tools import tool
@tool
def read_inbox(limit: int = 10) -> str:
"""Read the latest emails."""
return "...latest emails..."
@tool
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email."""
return f"Sent to {to}"
@tool
def delete_email(email_id: str) -> str:
"""Delete an email permanently."""
return f"Deleted {email_id}"Reading is safe; sending and deleting are not. Only the risky tools need approval.
Step 3: create the agent with approval middleware
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver
from tools import read_inbox, send_email, delete_email
agent = create_agent(
model="anthropic:claude-sonnet-4-5", # any supported chat model
tools=[read_inbox, send_email, delete_email],
middleware=[
HumanInTheLoopMiddleware(
interrupt_on={
"send_email": True, # approve, edit or reject
"delete_email": True,
"read_inbox": False, # runs without approval
},
interrupt_mode="per_call", # one interrupt per gated call
)
],
checkpointer=InMemorySaver(), # required to pause and resume
)Use a persistent checkpointer in production (for example a Postgres or Redis saver) so pending approvals survive restarts.
Step 4: run until it pauses
config = {"configurable": {"thread_id": "user-42"}}
result = agent.invoke(
{"messages": [{"role": "user", "content": "Reply to Priya that Friday works, then delete the spam email."}]},
config=config,
)
# Pending approvals appear as interrupts on the result
for interrupt in result.get("__interrupt__", []):
print(interrupt.id, interrupt.value)Each interrupt describes one proposed tool call: the tool name and its arguments. Show these to a human in your UI, Slack or email.
Step 5: approve, edit or reject, then resume
In per-call mode, you answer each interrupt by its ID with a single decision:
from langgraph.types import Command
decisions = {
send_interrupt_id: {"type": "approve"},
delete_interrupt_id: {"type": "reject", "message": "Don't delete anything yet."},
}
result = agent.invoke(Command(resume=decisions), config=config)To edit a call before it runs, for example to fix the email body, return an edit decision with the corrected arguments. The exact field names can vary between versions, so check the human-in-the-loop docs for your installed version.
A rejection message is passed back to the model, so it can explain or try a different approach.
Step 6: approve only when it matters
Approving every email gets tedious. The when option lets a predicate decide per call whether to interrupt, for example only for external recipients:
def is_external(tool_call) -> bool:
return not tool_call["args"]["to"].endswith("@ourcompany.com")Pass a predicate like this as the tool's when condition (see the middleware reference for the exact configuration shape in your version). Internal emails then go through automatically and external ones pause. This risk-based approval is how teams keep humans in control without slowing everything down.
Production checklist
- Persistent checkpointer so approvals survive restarts
- Every risky tool listed in
interrupt_on; safe read-only tools excluded -
interrupt_mode="per_call"for one approval per action - Approval UI shows tool name, arguments and context in plain language
- Approvals and rejections logged (who, when, what)
- Timeouts: decide what happens if nobody responds
- Conditional rules (
when) for low-risk cases - Permissions enforced in the tools themselves too, not only by approval
That last point matters: approval is one layer. Tools should still check permissions, because agents can be manipulated by content they read. See prompt injection explained and why agents need guardrails outside their reach.
Frequently asked questions
What is human-in-the-loop in LangChain?
It's a pattern where an agent pauses before running selected tool calls so a human can approve, edit or reject them. LangChain implements it with HumanInTheLoopMiddleware, which raises interrupts and uses a checkpointer to resume the agent afterwards.
What is per-call interrupt mode in LangChain?
Per-call mode, enabled with interrupt_mode="per_call" in langchain 1.4.6 or newer, raises a separate interrupt for each gated tool call. Each one is answered with a single decision keyed by its interrupt ID, instead of one batched list per model turn.
Why does human-in-the-loop need a checkpointer?
The checkpointer saves the agent's state when it pauses, so it can resume exactly where it stopped after a human decides. Without it, the pending tool call and conversation would be lost.
How do I approve only risky tool calls?
List only risky tools in interrupt_on and leave read-only tools out. For finer control, use the when predicate (langchain 1.3.3 or newer) to interrupt only when the arguments meet a condition, such as an external email recipient.
Can a human edit an agent's tool call before it runs?
Yes. Besides approve and reject, the middleware supports an edit decision that replaces the tool call's arguments before execution, for example to correct an email before it is sent.
What happens when a tool call is rejected?
The tool doesn't run, and the rejection message is passed back to the model so it can explain, ask for clarification or choose another approach.
Comments