LangChain (YT style)LangChain 1.4 · Python 3.12+
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

Why an agent needs a human, and the two email tools · from the Updated LangChain Version V1 Crash Course · 137:36 to 142:05

An email agent that asks first

An autonomous agent does its work without much human intervention, which is a risk when the work is critical. The video's example is an agent that buys stocks: one mistaken purchase could mean a large loss, so the agent should ask a person to confirm before it acts. HumanInTheLoopMiddleware does this: it pauses the agent before chosen tool calls run and waits for a person to approve, edit or reject the call. It fits high-stakes operations such as database writes and financial transactions, and compliance workflows that require a person to sign off. (A fourth decision, respond, comes in the next lesson.)

HumanInTheLoopMiddleware pauses before send_email_tool; a person approves, edits or rejects the call.
Approve, edit or reject

The video's agent sends email. It has two stand-in tools, one to read an email by its id and one to send an email; a real sender would need an SMTP server.

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

def read_email_tool(email_id: str) -> str:
    """Mock function to read an email by its ID."""
    return f"Email content for ID: {email_id}"

def send_email_tool(recipient: str, subject: str, body: str) -> str:
    """Mock function to send an email."""
    return f"Email sent to {recipient} with subject '{subject}'"
The agent with interrupt_on, paused before send_email_tool · from the Updated LangChain Version V1 Crash Course · 142:03 to 146:22
python
agent=create_agent(
    model="groq:openai/gpt-oss-120b",
    tools=[read_email_tool,send_email_tool],
    checkpointer=InMemorySaver(),
    middleware=[
        HumanInTheLoopMiddleware(
            interrupt_on={
                "send_email_tool":{
                    "allowed_decisions":["approve","edit","reject"]
                },
                "read_email_tool":False,

            }
        )
    ]
)

The agent gets the model, both tools and an InMemorySaver checkpointer to save the conversation. Plain functions work as tools; create_agent wraps them. interrupt_on says where to interrupt: send_email_tool pauses for a person, who may approve, edit or reject the call; read_email_tool is False, so it runs without asking. The checkpointer is required, because a paused agent has to be saved until the decision arrives. The video uses GPT-4o; here the agent runs on Groq.

Create a config with the thread id test-approve and ask the agent to send an email to john@test.com. The run stops before the send tool, and the result carries an __interrupt__ entry describing the waiting call, because interrupt_on put a trigger on send_email_tool:

python
from langchain.messages import HumanMessage

config = {"configurable": {"thread_id": "test-approve"}}

result = agent.invoke(
    {"messages": [HumanMessage(content="Send email to john@test.com with subject 'Hello' and body 'How are you?'")]},
    config=config,
)
Approving and rejecting with Command · from the Updated LangChain Version V1 Crash Course · 146:22 to 150:04

Approving

The approval goes back in as a Command, imported from langgraph.types. It resumes the workflow with the decision type approve, one of the allowed decisions, on the same config, so the agent picks up where it stopped and the email is sent:

ExampleAPI keyFrom the video, run on Groq
from langgraph.types import Command
# Step 2: Approve
if "__interrupt__" in result:
    print("⏸️ Paused! Approving...")
    
    result = agent.invoke(
        Command(
            resume={
                "decisions": [
                    {"type": "approve"}
                ]
            }
        ),
        config=config
    )
    
    print(f"✅ Result: {result['messages'][-1].content}")

Rejecting

Rejecting uses the same code on a new thread, test-reject, with the decision type changed to reject. The tool is skipped, the model is told the user rejected the tool call, and it writes a reply without it:

ExampleAPI keyFrom the video, run on Groq
config = {"configurable": {"thread_id": "test-reject"}}
result = agent.invoke(
    {"messages": [HumanMessage(content="Send email to john@test.com with subject 'Hello' and body 'How are you?'")]},
    config=config)

if "__interrupt__" in result:
    print("⏸️ Paused! Rejecting...")
    result = agent.invoke(Command(resume={"decisions": [{"type": "reject"}]}), config=config)
    print(f"✅ Result: {result['messages'][-1].content}")

With no reason attached to the rejection, the model could only say the email was not sent. The shop agent below attaches a message to its rejection, so the model knows what to tell the customer.

Now the shop. A refund cannot be undone, so it is the call a person should confirm. The shop adds a second tool, and the model will ask for it when a customer wants a refund.

HumanInTheLoopMiddleware and a checkpointer

python
# the shape of it; the full agent, with its model and tools, is built below
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=[lookup_order, refund_order], middleware=[approval], checkpointer=InMemorySaver(),
                     system_prompt="You are the support assistant for a small online shop. Answer in one or two short sentences, using only what the tools returned.")

The two tools

First the two tools: lookup_order from the tools lesson, and a new one, refund_order.

python
from langchain.tools import tool

ORDERS = {"A17": "shipped on 3 March", "C40": "waiting for stock"}


@tool
def lookup_order(order_id: str) -> str:
    """Look up an order's shipping status by its id, such as A17."""
    status = ORDERS.get(order_id)
    return f"{order_id} {status}." if status else f"{order_id} is not an order we have."


@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 pauses. lookup_order is not listed, so it runs as before.

python
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain.chat_models import init_chat_model
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command

model = init_chat_model("groq:openai/gpt-oss-120b", temperature=0)  # uses your GROQ_API_KEY
approval = HumanInTheLoopMiddleware(interrupt_on={"refund_order": True})
agent = create_agent(model, tools=[lookup_order, refund_order],
                     system_prompt="You are the support assistant for a small online shop. Answer in one or two short sentences, using only what the tools returned.",
                     middleware=[approval], checkpointer=InMemorySaver())

The pause

Ask for a refund. The video's notebook checks result["__interrupt__"]. Passing version="v2" to invoke gives the same information in a tidier shape, and the rest of the course uses it.

ExampleAPI key
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=...).

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

for message in result.value["messages"]:
    if message.type == "tool" or (message.type == "ai" and not message.tool_calls):   # the tool result and the reply
        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 message for the model. The model reads it as the tool's result, so it says what happened and what to pass on; without the last sentence the model tends to add advice of its own, such as whom else to contact. That thread already finished with the approval, so start a fresh pause on a new thread with the same request, then reject it.

ExampleAPI key
thread = {"configurable": {"thread_id": "ravi-refund-2"}}
agent.invoke(ask, thread, version="v2")                  # pauses before the refund again

decision = Command(resume={"decisions": [{"type": "reject", "message": "Not refunded: refunds need a manager's approval. Tell the customer only that."}]})
result = agent.invoke(decision, thread, version="v2")

for message in result.value["messages"]:
    if message.type == "tool" or (message.type == "ai" and not message.tool_calls):   # the tool result and the reply
        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.

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.