Skip to main content

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_on maps 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_id used 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

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:

  1. The config passed to the resume call uses a different thread_id than the original interrupted call (most common)
  2. 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:

  1. The call didn't actually trigger an interrupt at all — e.g., the agent didn't call a tool listed as True in interrupt_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

  1. What does interrupt_on={"send_email": True} actually do?

    Show Answer

    It tells HumanInTheLoopMiddleware to pause execution right before send_email runs, returning an interrupt instead, until a human supplies an approve/reject/edit decision.

  2. 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.

  3. 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_on maps 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_id as 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) — HumanInTheLoopMiddleware is part of core langchain
  • langgraph: >=1.0.3Command, 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