LangChainLangChain 1.4 · Python 3.10+
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
46 small wins to finish your pathNext lesson →

Asking a human first

HumanInTheLoopMiddleware is a middleware that pauses an agent before a chosen tool runs and waits for a person to approve or reject the call.

Last updated: 27 Sep, 2026 · LangChain 1.4

Some tools should not run on a model's word alone, such as a refund. The shop adds a second tool to tools.py, and the stand-in model from earlier asks for it when a message mentions a refund.

Where a person decides
Please refund A17the customerThe model asksrefund_orderThe run pausessaved by the checkpointerA person decidesapprove, edit, rejectThe refund runsor never does
Hover or tap a piece to see what it is and which lesson built it.
Follow a refund

Pick one to watch it run, step by step.

HumanInTheLoopMiddleware and a checkpointer

python
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver

approval = HumanInTheLoopMiddleware(interrupt_on={"refund_order": True})  # pause on this tool
# a checkpointer is required so the paused run can be resumed later
agent = create_agent(model, tools=tools, middleware=[approval], checkpointer=InMemorySaver())

The refund tool

First the new tool. It refunds an order, which cannot be undone, so it is the call a person should confirm.

python
@tool
def refund_order(order_id: str) -> str:
    """Refund an order in full. This cannot be undone."""
    return f"Refunded {order_id}."

The agent with approval

Build the agent with both tools and the middleware. interrupt_on names the tools that need a decision; True means every call to that tool pauses. lookup_order is not listed, so it runs as before. The checkpointer is required: a paused agent is saved, and the decision may come minutes or days later.

python
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from shop_model import ShopModel
from tools import lookup_order, refund_order

approval = HumanInTheLoopMiddleware(interrupt_on={"refund_order": True})
agent = create_agent(ShopModel(), tools=[lookup_order, refund_order],
                     middleware=[approval], checkpointer=InMemorySaver())

The pause

Ask for a refund. With version="v2", invoke returns an object with the state so far and whatever is waiting for a decision.

Example
thread = {"configurable": {"thread_id": "ravi-refund"}}
ask = {"messages": [{"role": "user", "content": "Please refund A17"}]}
result = agent.invoke(ask, thread, version="v2")

print(result.interrupts[0].value["action_requests"])
print(result.value["messages"][-1].tool_calls[0]["name"])

Invoking with version="v2" returns an object with two parts. value is the state so far, ending with the model's request to refund A17. interrupts holds what is waiting for a decision: the tool, its arguments and a description a reviewer can read. The refund has not run.

Approving

Send a decision back on the same thread with Command(resume=...).

Example
decision = Command(resume={"decisions": [{"type": "approve"}]})
result = agent.invoke(decision, thread, version="v2")

for message in result.value["messages"][2:]:
    print(f"{message.type:<4} {message.text}")

A Command with resume continues the same thread, and decisions holds one decision per paused call. After the approval the refund ran and the model answered.

Rejecting

Reject instead, with a reason for the model to relay.

Example
decision = Command(resume={"decisions": [{"type": "reject", "message": "Refunds need a manager."}]})
result = agent.invoke(decision, thread, version="v2")

for message in result.value["messages"][2:]:
    print(f"{message.type:<4} {message.text}")

A rejection never runs the tool. The model gets a tool message with the reason instead, so it can tell the customer why. Without a message, the middleware writes a default one telling the model not to try the same call again.

What each decision did

  • The run paused before the refund. With version="v2", invoke returned value (the state so far) and interrupts (the pending call); the refund had not run.
  • Approve runs the tool. Command(resume=...) with {"type": "approve"} continued the thread, the refund ran, and the model confirmed it.
  • Reject skips the tool. The model got a tool message with the reason instead, so it could explain the refusal.
  • The checkpointer holds the pause. The decision can arrive any time later because the paused state is saved.

Approve vs reject

approvereject
Runs the toolYesNo
Tool messageThe tool's real resultThe reason, or a default refusal
Model thenUses the result in its replyExplains why it could not act

Where a pause for approval fits

  • A refund, a delete, or any action that cannot be undone.
  • A step that spends money or emails a customer, held for a person to confirm.
  • A review queue where approvals arrive minutes or days after the request.
Watch out. HumanInTheLoopMiddleware needs a checkpointer. Without one there is nowhere to save the paused run, and the resume has nothing to continue.
Try it yourself
  • Ask about A17 without a refund and check that the agent does not pause.
  • Print result.interrupts[0].value["review_configs"] to see which decisions are allowed.
  • After the pause, print agent.get_state(thread).next to see where the run is waiting.

You understood something today that you didn't yesterday.