# How to Add Human-in-the-Loop to an AI Agent

- Tool: Any LLM or framework
- Difficulty: Intermediate
- Time required: 30-45 min
- Compatibility: Python 3.10+, langchain 1.4+, langgraph 1.2+, an Anthropic API key, any OS
- Last updated: November 2026

## TL;DR

Human-in-the-loop means an agent pauses before a specific action and waits for a person to approve, edit, or reject it, instead of running autonomously end to end. This guide builds a contract-review agent that reads contracts freely but always stops before proposing a clause edit, using LangChain's HumanInTheLoopMiddleware with a checkpointer. Budget 30-45 minutes and an Anthropic API key.

## What you'll build

You'll build a contract-review agent for a small legal team. It can read a contract and flag risky clauses freely, that part runs end to end with no pause. But the moment it wants to propose an actual edit to clause language, the run stops dead and waits for an attorney to approve the edit, change it, or reject it with a reason, before the agent's suggestion goes anywhere near the document the client sees.

This is what human-in-the-loop actually means in production: not a chat window where a person reads everything, but specific tool calls that halt the agent's execution graph until a decision comes back. LangChain's HumanInTheLoopMiddleware wraps create_agent and lets you configure, per tool, whether it interrupts at all and which decisions (approve, edit, reject) are valid responses. Under the hood this is LangGraph's interrupt mechanism: the graph literally pauses, persists its state through a checkpointer, and only continues when you call it again with a Command(resume=...).

Add this when a tool's blast radius is large enough that a wrong call is expensive to undo, like a contract edit, a financial transaction, or a message sent externally. Skip it for tools where being wrong just means a slightly worse answer the user can immediately see and correct themselves.

## Before you start

- Python 3.10+ and a terminal
- An Anthropic API key (ANTHROPIC_API_KEY)
- pip install langchain langgraph
- Basic comfort with async-free, synchronous Python control flow

## Step-by-step guide

### 1. Install the packages and define the two tools

Install langchain and langgraph. Define read_clause as a plain read-only tool and propose_clause_edit as the one that should never run without a human seeing it first. Both are ordinary @tool functions; nothing about their definition marks one as sensitive yet, that happens in the middleware configuration next.

```
pip install langchain langgraph
export ANTHROPIC_API_KEY="sk-ant-..."

from langchain.tools import tool

CONTRACT = {
    "termination": "Either party may terminate with 90 days written notice.",
    "liability": "Liability is uncapped for breach of confidentiality.",
}

@tool
def read_clause(clause_name: str) -> str:
    """Read the current text of a named clause in the contract."""
    return CONTRACT.get(clause_name, f"No clause named {clause_name}")

@tool
def propose_clause_edit(clause_name: str, new_text: str, reason: str) -> str:
    """Propose new text for a clause, with a reason. Requires attorney approval."""
    CONTRACT[clause_name] = new_text
    return f"Updated {clause_name}: {new_text}"
```

**Expected result:** Two tool functions, callable on their own; propose_clause_edit still just runs immediately if called directly, with no approval logic yet.

### 2. Build the agent with HumanInTheLoopMiddleware

create_agent takes a checkpointer directly, which this pattern requires since an interrupted run has to persist its state until a human responds, possibly minutes or hours later. interrupt_on maps a tool's name to either True (any decision allowed) or a dict restricting which decisions are valid; here, edits are only ever approved or rejected, never silently modified by the middleware itself.

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

agent = create_agent(
    model="anthropic:claude-sonnet-5-5",
    tools=[read_clause, propose_clause_edit],
    middleware=[
        HumanInTheLoopMiddleware(
            interrupt_on={
                "propose_clause_edit": {"allowed_decisions": ["approve", "reject"]},
                "read_clause": False,
            },
            description_prefix="Clause edit pending attorney review",
        ),
    ],
    checkpointer=InMemorySaver(),
)
```

**Expected result:** An agent object where read_clause runs freely and propose_clause_edit is wired to interrupt, but nothing has actually run yet.

### 3. Run the agent and detect the interrupt

Invoke with a thread_id so the paused state can be resumed later from the same place. When the model decides to call propose_clause_edit, the run stops before executing it and result.interrupts holds the pending action, not an exception and not a partial answer.

```
config = {"configurable": {"thread_id": "contract-review-acme-1"}}
result = agent.invoke(
    {"messages": [{"role": "user", "content": "Review the liability clause and tighten it if it's too broad"}]},
    config=config,
    version="v2",
)

if result.interrupts:
    for pending in result.interrupts:
        print(f"[PENDING APPROVAL] {pending}")
```

**Expected result:** result.interrupts is non-empty, containing the proposed liability clause edit and reason, with nothing written to CONTRACT yet.

### 4. Resume with an attorney's decision

Call agent.invoke() again, but this time with a Command(resume=...) instead of a new message, passing decisions in the same order as the pending interrupts. Approving lets the original tool call execute exactly as proposed; rejecting with a message tells the model why, so it can try a different edit instead of repeating the same one.

```
from langgraph.types import Command

# Attorney approves this one
result = agent.invoke(
    Command(resume={"decisions": [{"type": "approve"}]}),
    config=config,
    version="v2",
)
print(result["messages"][-1].content)
```

**Expected result:** The liability clause in CONTRACT is now updated, and the agent's final message confirms the change.

### 5. Handle a rejection and let the agent retry

A rejection isn't a dead end, it's feedback. Pass type: 'reject' with a message explaining why, and the model sees that as a tool result it can reason about, typically proposing a different edit on its next turn within the same thread.

```
result = agent.invoke(
    Command(resume={"decisions": [{"type": "reject", "message": "Too aggressive, keep some liability cap for ordinary breach"}]}),
    config=config,
    version="v2",
)
# The agent typically proposes a revised edit here, interrupting again
if result.interrupts:
    print(f"[PENDING APPROVAL, revised] {result.interrupts}")
```

**Expected result:** A new interrupt appears with a revised clause proposal that accounts for the rejection reason, instead of the run simply ending.

### 6. Harden it: route decisions through a real approval channel

A script typing approve or reject only works for testing. In production, serialize the pending interrupt (clause name, proposed text, reason) into a Slack message or an internal review queue with approve and reject actions, and call agent.invoke() with the Command(resume=...) from whatever handler receives that response, using the same thread_id you stored alongside the pending request.

```
def notify_attorney(thread_id: str, pending) -> None:
    # Replace with a real Slack message or review-queue ticket
    print(f"[SLACK] New clause edit awaiting review (thread={thread_id}): {pending}")

# After agent.invoke() returns with result.interrupts non-empty:
if result.interrupts:
    notify_attorney(config["configurable"]["thread_id"], result.interrupts)
```

**Expected result:** The approval request leaves the script's own process, and resuming the run later only needs the stored thread_id, not any in-memory state.

## Complete code example

File: `contract_review_agent.py`

```python
"""Contract-review agent: reads clauses freely, but every proposed edit
pauses for attorney approval via LangChain's HumanInTheLoopMiddleware.
Run: python contract_review_agent.py (pip install langchain langgraph)
"""
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command

CONTRACT = {
    "termination": "Either party may terminate with 90 days written notice.",
    "liability": "Liability is uncapped for breach of confidentiality.",
}


@tool
def read_clause(clause_name: str) -> str:
    """Read the current text of a named clause in the contract."""
    return CONTRACT.get(clause_name, f"No clause named {clause_name}")


@tool
def propose_clause_edit(clause_name: str, new_text: str, reason: str) -> str:
    """Propose new text for a clause, with a reason. Requires attorney approval."""
    CONTRACT[clause_name] = new_text
    return f"Updated {clause_name}: {new_text}"


agent = create_agent(
    model="anthropic:claude-sonnet-5-5",
    tools=[read_clause, propose_clause_edit],
    middleware=[
        HumanInTheLoopMiddleware(
            interrupt_on={
                "propose_clause_edit": {"allowed_decisions": ["approve", "reject"]},
                "read_clause": False,
            },
            description_prefix="Clause edit pending attorney review",
        ),
    ],
    checkpointer=InMemorySaver(),
)


def notify_attorney(thread_id: str, pending) -> None:
    # Replace with a real Slack message or internal review-queue ticket
    print(f"[SLACK] New clause edit awaiting review (thread={thread_id}): {pending}")


if __name__ == "__main__":
    config = {"configurable": {"thread_id": "contract-review-acme-1"}}

    result = agent.invoke(
        {
            "messages": [
                {"role": "user", "content": "Review the liability clause and tighten it if it's too broad"}
            ]
        },
        config=config,
        version="v2",
    )

    if result.interrupts:
        notify_attorney(config["configurable"]["thread_id"], result.interrupts)
        print(f"[PENDING APPROVAL] {result.interrupts}")

        # Simulate an attorney approving it
        result = agent.invoke(
            Command(resume={"decisions": [{"type": "approve"}]}),
            config=config,
            version="v2",
        )
        print(result["messages"][-1].content)
    else:
        print(result["messages"][-1].content)
```

## Common mistakes

- **Setting interrupt_on on a tool but forgetting to pass a checkpointer to create_agent** — undefined Fix: An interrupted run has nowhere to persist its paused state without one. Always pair HumanInTheLoopMiddleware with a checkpointer, even InMemorySaver for development.
- **Reading result.interrupts as a final failure and not resuming the run** — undefined Fix: An interrupt isn't an error, it's a pause. The run is still alive under its thread_id and expects a Command(resume=...) call, not a fresh invoke() with a new message.
- **Gating every tool behind approval, including read-only ones** — undefined Fix: Approval fatigue makes people rubber-stamp everything. Reserve interrupt_on for tools whose blast radius actually justifies the friction, like read_clause being excluded here.
- **Building the approval UI as a blocking input() prompt and shipping it** — undefined Fix: Route the pending interrupt to Slack, email, or a review queue, and resume from whatever handler receives the response, using the stored thread_id.
- **Passing resume decisions in the wrong order relative to the pending interrupts** — undefined Fix: Decisions in Command(resume={'decisions': [...]}) must match the order of result.interrupts. Match them by inspecting each pending action, not by assumption.

## Best practices

- Reserve interrupt_on for tools with real blast radius; log everything else instead of gating it.
- Always pass a checkpointer alongside HumanInTheLoopMiddleware; a paused run needs somewhere to persist.
- Give every interrupted tool call a clear description_prefix so whoever approves it understands what they're approving without reading code.
- Treat a rejection as feedback the model can act on, with a message explaining why, not just a dead end.
- Store the thread_id alongside the pending-approval request in your review queue; resuming later needs nothing else.
- Restrict allowed_decisions per tool (approve/reject only, no silent edit) when the tool's effect shouldn't be modified outside the model's own reasoning.

## Use cases

### undefined



### undefined



### undefined



## Frequently asked questions

### What's the difference between human-in-the-loop and just asking the user to confirm in chat?

Asking in chat relies on the model to pause itself, which it can forget to do under the wrong prompt. HumanInTheLoopMiddleware enforces the pause at the framework level before the tool executes, independent of what the model says it's going to do.

### Does the agent keep running other tools while waiting for approval?

No. The entire graph pauses at the interrupt. If you need some work to continue while one specific action waits for approval, structure that as a separate agent or workflow branch rather than one linear run.

### Can a human edit the tool's arguments instead of just approving or rejecting?

Yes, HumanInTheLoopMiddleware supports an edit decision alongside approve and reject; this guide restricts propose_clause_edit to approve/reject only to keep the example focused, but allowed_decisions can include edit for tools where that makes sense.

### What happens if nobody responds to a pending interrupt?

The run simply stays paused, persisted by the checkpointer, with no timeout built into the middleware itself. Add your own expiration logic in your review queue if an unanswered approval should eventually be escalated or auto-rejected.

### Is this the same mechanism as the Claude Agent SDK's canUseTool callback?

They solve the same problem differently. canUseTool is a synchronous permission callback evaluated at the moment of a tool call; LangGraph's interrupt actually pauses and persists the entire graph so the approval can come back minutes or days later.

### Can someone build this approval workflow for me?

Yes. RapidDev builds human-in-the-loop approval flows on LangGraph and the Claude Agent SDK, including the Slack and review-queue integration most tutorials skip. A free 30-minute consultation is the fastest way to scope your version.

---

Source: https://www.rapidevelopers.com/build-ai-agent/how-to-add-human-in-the-loop-to-an-ai-agent
© RapidDev — https://www.rapidevelopers.com/build-ai-agent/how-to-add-human-in-the-loop-to-an-ai-agent
