Skip to main content
RapidDev - Software Development Agency
build-ai-agent

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

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 learn

  • Configure HumanInTheLoopMiddleware so only specific tools interrupt the agent run
  • Detect an interrupted run and read the pending decisions it's waiting on
  • Resume a paused agent with Command(resume=...) after a human approves, edits, or rejects
  • Decide which actions need approval and which only need a log entry
Book a free consultation
4.9Clutch rating ⭐
1,000+Happy partners
20+Countries served
200+Team members
Intermediate9 min read30-45 minPython 3.10+, langchain 1.4+, langgraph 1.2+, an Anthropic API key, any OSLast updated November 2026RapidDev Engineering Team
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.

Quick facts about this guide
FactValue
ToolAny LLM or framework
DifficultyIntermediate
Time required30-45 min
CompatibilityPython 3.10+, langchain 1.4+, langgraph 1.2+, an Anthropic API key, any OS
Last updatedNovember 2026

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.

Prerequisites

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

typescript
1pip install langchain langgraph
2export ANTHROPIC_API_KEY="sk-ant-..."
3
4from langchain.tools import tool
5
6CONTRACT = {
7 "termination": "Either party may terminate with 90 days written notice.",
8 "liability": "Liability is uncapped for breach of confidentiality.",
9}
10
11@tool
12def read_clause(clause_name: str) -> str:
13 """Read the current text of a named clause in the contract."""
14 return CONTRACT.get(clause_name, f"No clause named {clause_name}")
15
16@tool
17def propose_clause_edit(clause_name: str, new_text: str, reason: str) -> str:
18 """Propose new text for a clause, with a reason. Requires attorney approval."""
19 CONTRACT[clause_name] = new_text
20 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.

typescript
1from langchain.agents import create_agent
2from langchain.agents.middleware import HumanInTheLoopMiddleware
3from langgraph.checkpoint.memory import InMemorySaver
4
5agent = create_agent(
6 model="anthropic:claude-sonnet-5-5",
7 tools=[read_clause, propose_clause_edit],
8 middleware=[
9 HumanInTheLoopMiddleware(
10 interrupt_on={
11 "propose_clause_edit": {"allowed_decisions": ["approve", "reject"]},
12 "read_clause": False,
13 },
14 description_prefix="Clause edit pending attorney review",
15 ),
16 ],
17 checkpointer=InMemorySaver(),
18)

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.

typescript
1config = {"configurable": {"thread_id": "contract-review-acme-1"}}
2result = agent.invoke(
3 {"messages": [{"role": "user", "content": "Review the liability clause and tighten it if it's too broad"}]},
4 config=config,
5 version="v2",
6)
7
8if result.interrupts:
9 for pending in result.interrupts:
10 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.

typescript
1from langgraph.types import Command
2
3# Attorney approves this one
4result = agent.invoke(
5 Command(resume={"decisions": [{"type": "approve"}]}),
6 config=config,
7 version="v2",
8)
9print(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.

typescript
1result = agent.invoke(
2 Command(resume={"decisions": [{"type": "reject", "message": "Too aggressive, keep some liability cap for ordinary breach"}]}),
3 config=config,
4 version="v2",
5)
6# The agent typically proposes a revised edit here, interrupting again
7if result.interrupts:
8 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.

typescript
1def notify_attorney(thread_id: str, pending) -> None:
2 # Replace with a real Slack message or review-queue ticket
3 print(f"[SLACK] New clause edit awaiting review (thread={thread_id}): {pending}")
4
5# After agent.invoke() returns with result.interrupts non-empty:
6if result.interrupts:
7 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 working example

contract_review_agent.py
1"""Contract-review agent: reads clauses freely, but every proposed edit
2pauses for attorney approval via LangChain's HumanInTheLoopMiddleware.
3Run: python contract_review_agent.py (pip install langchain langgraph)
4"""
5from langchain.agents import create_agent
6from langchain.agents.middleware import HumanInTheLoopMiddleware
7from langchain.tools import tool
8from langgraph.checkpoint.memory import InMemorySaver
9from langgraph.types import Command
10
11CONTRACT = {
12 "termination": "Either party may terminate with 90 days written notice.",
13 "liability": "Liability is uncapped for breach of confidentiality.",
14}
15
16
17@tool
18def read_clause(clause_name: str) -> str:
19 """Read the current text of a named clause in the contract."""
20 return CONTRACT.get(clause_name, f"No clause named {clause_name}")
21
22
23@tool
24def propose_clause_edit(clause_name: str, new_text: str, reason: str) -> str:
25 """Propose new text for a clause, with a reason. Requires attorney approval."""
26 CONTRACT[clause_name] = new_text
27 return f"Updated {clause_name}: {new_text}"
28
29
30agent = create_agent(
31 model="anthropic:claude-sonnet-5-5",
32 tools=[read_clause, propose_clause_edit],
33 middleware=[
34 HumanInTheLoopMiddleware(
35 interrupt_on={
36 "propose_clause_edit": {"allowed_decisions": ["approve", "reject"]},
37 "read_clause": False,
38 },
39 description_prefix="Clause edit pending attorney review",
40 ),
41 ],
42 checkpointer=InMemorySaver(),
43)
44
45
46def notify_attorney(thread_id: str, pending) -> None:
47 # Replace with a real Slack message or internal review-queue ticket
48 print(f"[SLACK] New clause edit awaiting review (thread={thread_id}): {pending}")
49
50
51if __name__ == "__main__":
52 config = {"configurable": {"thread_id": "contract-review-acme-1"}}
53
54 result = agent.invoke(
55 {
56 "messages": [
57 {"role": "user", "content": "Review the liability clause and tighten it if it's too broad"}
58 ]
59 },
60 config=config,
61 version="v2",
62 )
63
64 if result.interrupts:
65 notify_attorney(config["configurable"]["thread_id"], result.interrupts)
66 print(f"[PENDING APPROVAL] {result.interrupts}")
67
68 # Simulate an attorney approving it
69 result = agent.invoke(
70 Command(resume={"decisions": [{"type": "approve"}]}),
71 config=config,
72 version="v2",
73 )
74 print(result["messages"][-1].content)
75 else:
76 print(result["messages"][-1].content)
77

Common mistakes

Why it's a problem: Setting interrupt_on on a tool but forgetting to pass a checkpointer to create_agent

How to avoid: An interrupted run has nowhere to persist its paused state without one. Always pair HumanInTheLoopMiddleware with a checkpointer, even InMemorySaver for development.

Why it's a problem: Reading result.interrupts as a final failure and not resuming the run

How to avoid: 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.

Why it's a problem: Gating every tool behind approval, including read-only ones

How to avoid: 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.

Why it's a problem: Building the approval UI as a blocking input() prompt and shipping it

How to avoid: Route the pending interrupt to Slack, email, or a review queue, and resume from whatever handler receives the response, using the stored thread_id.

Why it's a problem: Passing resume decisions in the wrong order relative to the pending interrupts

How to avoid: 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.

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I have a LangChain agent with HumanInTheLoopMiddleware gating a propose_clause_edit tool behind approve/reject decisions, using a checkpointer and thread_id (code below). Add a second gated tool, send_to_counterparty, that also requires approval, and make sure both tools' pending interrupts resume correctly in the same call. [paste complete_code]

Build AI Agents Prompt

In Claude Code, open contract_review_agent.py and ask: 'Replace the print-based notify_attorney function with one that posts the pending interrupt to a Slack webhook, keeping the same thread_id for resuming later.'

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.

RapidDev

Talk to an Expert

Our team has built 1,000+ apps. Get personalized help with your project.

Book a free consultation
Matt Graham

Written by

Matt Graham · CEO & Founder, RapidDev

1,000+ client projects delivered. Columbia University & Harvard Business School alumnus, U.S. Navy veteran. About the author →

Learning is great. Shipping is faster with help.

Our engineers have built 1,000+ apps on your platform and the tools around it. If your project needs to be live sooner than your learning curve allows — book a free consultation.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.