Human-in-the-Loop: Approve, Reject, Edit
Aria has been allowed to send emails entirely on her own, this whole series. That's been fine for learning — but would you actually want an assistant sending real emails on your behalf with zero oversight? Reading the inbox, searching the web, looking things up — those are safe to let her handle freely. Sending something is different: it's irreversible the moment it happens.
This article adds a real checkpoint before anything irreversible: human-in-the-loop (HITL). Using the middleware concept from Article 12, we'll pause Aria right before she sends an email, and give a human the power to approve it, reject it, or edit it first.
🔴 Skill level: Advanced.
Quick Reference
When to use this: Whenever an agent's action is risky, costly, or irreversible enough that a human should confirm it before it actually happens.
Basic syntax:
from langchain.agents.middleware import HumanInTheLoopMiddleware
agent = create_agent(
model="gpt-5-nano",
tools=[read_email, send_email],
checkpointer=InMemorySaver(),
middleware=[
HumanInTheLoopMiddleware(interrupt_on={"read_email": False, "send_email": True}),
],
)
Common patterns:
interrupt_onmaps each tool name to whether it requires approval (True) or runs freely (False)- When an approval-required tool is about to run, the agent pauses and returns an interrupt instead of executing it
- Resuming uses
Command(resume={"decisions": [...]})with"approve","reject", or"edit"
Gotchas:
- ⚠️ A checkpointer is required for HITL — the paused state has to be saved somewhere so it can be resumed later, exactly like the memory mechanism from Article 4.
- ⚠️ Resuming requires the exact same
thread_idused when the interrupt happened — a different thread has no memory of the pending approval.
See also: Managing Conversation History: Summarization and Trimming, Custom Agent State: Reading and Writing Beyond Messages
What You Need to Know First
- Everything from Articles 1–12, especially memory (Article 4), custom state (Article 8), and middleware (Article 12)
What We'll Cover in This Article
- Why some tool calls deserve a human checkpoint and others don't
- How to configure which tools require approval
- How to inspect a pending action before deciding
- How to approve, reject, or edit a pending action
What We'll Explain Along the Way
- What an "interrupt" means in this context
- Why a checkpointer is required for this to work at all
Why Some Actions Need a Human in the Loop
Think of a genuinely excellent human assistant: you'd trust them to check your mail, draft replies, and look things up without asking permission for every step. But you'd still want them to check with you before actually dropping a stamped envelope in the mailbox — once it's sent, it's sent. The line isn't about how smart the assistant is; it's about which actions are safe to undo versus which ones aren't.
HumanInTheLoopMiddleware lets you draw that exact line, tool by tool. Using the middleware concept from Article 12, it inserts a checkpoint right before specific tool calls actually execute — pausing the agent entirely until a human makes a decision.
Setting Up Human-in-the-Loop
Let's rebuild Aria's inbox tools with this protection in place. First, the tools themselves — read_email reads from state (set up the same way as Article 8), and send_email is the action we actually want to gate:
# Purpose: Define Aria's inbox tools, one of which we'll protect with HITL
# Context: read_email is safe to run freely; send_email is the risky one
# Input: None directly — read_email pulls from state
# Output: Either the email content, or a confirmation that an email was sent
from dotenv import load_dotenv
load_dotenv()
from langchain.tools import tool, ToolRuntime
@tool
def read_email(runtime: ToolRuntime) -> str:
"""Read the current email from the inbox."""
return runtime.state["email"]
@tool
def send_email(body: str) -> str:
"""Send a reply email with the given body."""
# In a real application, this would actually send an email.
return "Email sent"
Now, a custom state to hold the current email (same pattern from Article 8), and HumanInTheLoopMiddleware configured to require approval only for send_email:
# Purpose: Configure an agent that pauses before sending, but not before reading
# Context: interrupt_on maps each tool name to whether approval is required
# Input: N/A
# Output: An agent that will interrupt execution before send_email runs
from langchain.agents import create_agent, AgentState
from langgraph.checkpoint.memory import InMemorySaver
from langchain.agents.middleware import HumanInTheLoopMiddleware
class EmailState(AgentState):
email: str
agent = create_agent(
model="gpt-5-nano",
tools=[read_email, send_email],
state_schema=EmailState,
checkpointer=InMemorySaver(), # required — the pause has to be saved somewhere
middleware=[
HumanInTheLoopMiddleware(
interrupt_on={
"read_email": False, # runs freely, no approval needed
"send_email": True, # pauses for approval before running
},
description_prefix="Tool execution requires approval",
),
],
)
Notice the checkpointer isn't optional here — when the agent pauses, that paused state has to be saved somewhere so it can be picked back up later, possibly much later, possibly after your program has restarted. This is the exact same persistence mechanism from Article 4, doing a new job.
Triggering an Interrupt
Let's give Aria a real scenario: Jane's coffee email is sitting in the inbox, and Julie asks Aria to read it and reply.
# Purpose: Trigger a real interrupt by asking Aria to send a reply
# Context: send_email requires approval, so execution pauses before it runs
# Input: A request to read and reply to an email, plus the email content in state
# Output: A response containing an __interrupt__ instead of a sent email
from langchain.messages import HumanMessage
config = {"configurable": {"thread_id": "1"}}
response = agent.invoke(
{
"messages": [HumanMessage(
content="Please read my email and send a reply confirming coffee."
)],
"email": "Hi Julie, I'm going to be in town next week — coffee? - Jane",
},
config=config,
)
print(response["__interrupt__"])
Instead of a normal final answer, response contains an __interrupt__ — execution stopped right before send_email actually ran. Let's look at exactly what's pending:
# Purpose: Inspect the specific action waiting for approval
# Context: Lets a human review exactly what Aria is about to do, before it happens
# Input: The interrupted response from above
# Output: The proposed email body, ready for a human to review
pending_body = response["__interrupt__"][0].value["action_requests"][0]["args"]["body"]
print(pending_body)
This is the actual draft Aria wants to send — visible and reviewable, before it's gone out anywhere.
Approving
If the draft looks good, resume the same thread with an "approve" decision:
# Purpose: Approve the pending action, letting it actually execute
# Context: Must use the same config (same thread_id) as the original call
# Input: An approval decision
# Output: A response showing the email was actually sent
from langgraph.types import Command
response = agent.invoke(
Command(resume={"decisions": [{"type": "approve"}]}),
config=config, # same thread_id — this is what connects to the paused state
)
print(response["messages"][-1].content)
send_email now actually runs, using exactly the draft that was shown for approval, and the conversation continues from where it paused.
Rejecting
If the draft isn't right, reject it instead — with a message explaining why, which becomes feedback Aria can act on:
# Purpose: Reject the pending action, with feedback for Aria to reconsider
# Context: The agent receives the rejection message and can try a different approach
# Input: A rejection decision with an explanation
# Output: A response where the agent adjusts based on the feedback
response = agent.invoke(
Command(resume={
"decisions": [{
"type": "reject",
"message": "Too casual — make it a bit more formal and confirm a specific day.",
}]
}),
config=config,
)
print(response["__interrupt__"][0].value["action_requests"][0]["args"]["body"])
Aria doesn't just stop — she takes the rejection message as feedback and proposes a new draft, which triggers a new interrupt for you to review again.
Editing
Sometimes you don't want to explain what's wrong — you just want to fix it yourself directly:
# Purpose: Override the pending action's arguments directly, then let it run
# Context: Skips back-and-forth — you supply the final version yourself
# Input: An edit decision with the corrected arguments
# Output: A response where send_email runs with YOUR edited content, not Aria's draft
response = agent.invoke(
Command(resume={
"decisions": [{
"type": "edit",
"edited_action": {
"name": "send_email",
"args": {"body": "Hi Jane, would Thursday at 10am work for coffee?"},
},
}]
}),
config=config,
)
print(response["messages"][-1].content)
This time, send_email runs with the exact arguments you specified — Aria's original draft is discarded entirely in favor of your edit.
Common Misconceptions
❌ Misconception: interrupt_on: True blocks a tool from ever running
Reality: True means the tool requires approval before running, not that it's blocked outright. Once approved (or edited), it runs normally.
Why this matters: interrupt_on is a routing decision (approval required vs. not), not a permission switch that disables a tool entirely.
❌ Misconception: Rejecting an action ends the conversation
Reality: A rejection is fed back to the agent as information, and it typically responds by proposing a revised action — which triggers a new interrupt for you to review, rather than the conversation simply stopping.
Why this matters: Don't expect rejection to be a hard stop — it's closer to giving editorial feedback than hitting a kill switch.
Troubleshooting Common Issues
Problem: Resuming with Command(resume={...}) doesn't seem to work
Symptoms: An error, or the resumed call behaves like a brand new conversation instead of continuing the paused one.
Common Causes:
- The
configpassed to the resume call uses a differentthread_idthan the original interrupted call (most common) - No checkpointer was configured at all, so there was nothing to resume from in the first place
Diagnostic Steps:
# Step 1: Confirm the exact same config object (or at least same thread_id) is reused
config = {"configurable": {"thread_id": "1"}}
agent.invoke({"messages": [...], "email": "..."}, config=config) # original call
agent.invoke(Command(resume={...}), config=config) # resume — SAME config
Solution: Reuse the exact same config (or at minimum, the exact same thread_id string) for both the original interrupted call and every resume call that follows.
Prevention: Store config in a variable once per conversation, the same prevention advice as the memory troubleshooting in Article 4 — this is the same underlying requirement.
Problem: KeyError accessing __interrupt__ or its contents
Symptoms: Code trying to read response["__interrupt__"][0]... fails.
Common Causes:
- The call didn't actually trigger an interrupt at all — e.g., the agent didn't call a tool listed as
Trueininterrupt_on
Solution: Check whether "__interrupt__" is even present in response before assuming its structure — if "__interrupt__" in response: — since a call that never reaches a gated tool won't have one.
Check Your Understanding
Quick Quiz
-
What does
interrupt_on={"send_email": True}actually do?Show Answer
It tells
HumanInTheLoopMiddlewareto pause execution right beforesend_emailruns, returning an interrupt instead, until a human supplies an approve/reject/edit decision. -
Why is a checkpointer required for human-in-the-loop to work?
Show Answer
Because the paused state has to be saved somewhere so it can be resumed later — potentially much later, even after a program restart. Without a checkpointer, there'd be nothing to resume from.
-
What happens after you reject a proposed action with a feedback message?
Show Answer
The agent receives that message as feedback and typically proposes a revised action, which triggers a new interrupt — the conversation continues, it doesn't simply stop.
Hands-On Exercise
Challenge: Add read_email and a second hypothetical tool, archive_email, to interrupt_on, requiring approval for archive_email but not read_email, and confirm only the archive action triggers an interrupt.
Show Solution
from langchain.tools import tool
@tool
def archive_email() -> str:
"""Archive the current email."""
return "Email archived"
agent = create_agent(
model="gpt-5-nano",
tools=[read_email, send_email, archive_email],
state_schema=EmailState,
checkpointer=InMemorySaver(),
middleware=[
HumanInTheLoopMiddleware(
interrupt_on={
"read_email": False,
"send_email": True,
"archive_email": True,
},
),
],
)
Explanation: Each tool gets its own independent entry in interrupt_on — there's no limit to how many tools can require approval, and each is evaluated separately based on which specific tool the agent is about to call.
Summary: Key Takeaways
- Human-in-the-loop pauses an agent right before specific, designated tool calls actually execute
interrupt_onmaps each tool name to whether it requires approval (True) or runs freely (False)- A checkpointer is required — the paused state must be saved somewhere to be resumed
- Resuming requires the exact same
thread_idas the original interrupted call - Three resume decisions are available: approve (run as proposed), reject (give feedback, agent tries again), edit (override the arguments directly)
- Aria can now be trusted with safe actions automatically, while anything irreversible — like actually sending an email — waits for a real human decision first
Version Information
Tested with:
- Python:
>=3.10, <4.0 langchain:>=1.1.3(latest stable as of writing:1.3.4) —HumanInTheLoopMiddlewareis part of corelangchainlanggraph:>=1.0.3—Command, already used since Article 8
Known issues:
- None specific to this article's functionality at the time of writing.
What's Next?
You now understand how to insert a real human approval step before risky or irreversible agent actions.
The natural next step is Dynamic Middleware: Adapting Prompts, Tools, and Models at Runtime — middleware so far has run the same way for everyone. That article covers making middleware itself adapt based on who's using Aria and what's happening in the conversation.
References
- LangChain Academy: Introduction to LangChain (Python) — this section is inspired by and adapted from this course
- LangChain Docs: Human-in-the-Loop — official guide to
HumanInTheLoopMiddleware - LangGraph Docs: Persistence — official guide to checkpointers, relevant again here since interrupts depend on them
langgraphon PyPI — latest version and release history