What is Human-in-the-Loop?
Human-in-the-Loop is an AI design pattern for LLM apps and agents, in the Agent behavior group. Pause at defined points for a human to approve, edit, or reject.
Humano no Circuito
Human-in-the-Loop is an AI design pattern for LLM apps and agents, in the Agent behavior group. Pause at defined points for a human to approve, edit, or reject.
Pause the run at defined points so a person can approve, edit, or reject what the system is about to do. It is for flows where a model mistake costs more than a few seconds or hours of waiting.
The co-pilot who reads the checklist aloud and waits for “check” before every critical step of takeoff.
Rafael is responsible for a law firm’s legal assistant. The assistant reviews client contracts, flags risky clauses, and drafts suggested changes. In the pilot, suggestions went straight to the client by email, because “the model is very good”.
In week three, the assistant suggested removing a jurisdiction clause the client needed to keep, with a convincing and wrong justification. The client accepted. The problem only surfaced months later, in a lawsuit.
Dropping the assistant was not an option: it saved the lawyers hours of work. Asking the lawyers to “check everything” did not work either: with no clear review point, nobody knew what had already been checked.
This kind of error has already left the lab. In Moffatt v. Air Canada (2024), the airline’s chatbot invented a bereavement-fare policy; the British Columbia Civil Resolution Tribunal held the company responsible for what the bot stated. In 2023, a Chevrolet dealership chatbot in Watsonville “agreed” to sell a Tahoe for $1 after a prompt injection (an injected instruction). In both cases, a claim or an effect went out with no person (and no system of record) in the middle.
The question this pattern answers: where and how do you put a person in the flow so the system stays useful and expensive mistakes do not get out?
Choose the points in the flow where a human decision changes the risk: before an irreversible effect, before something goes outside, when the model’s confidence is low, or when the rules require an accountable person. At each point, the system:
The person does not review everything; they review what matters, in a format built for quick review. Decisions become data: approvals, edits, and rejections feed the eval and show where the system goes wrong.
Approval must come from a channel that neither the model nor outside content can write to. A “yes” in the same chat that reads third-party email proves nothing. The preview shows system data (the recipient name from the bank, the clause text from the contract), not the model’s convincing prose.
Rafael’s assistant proposes an email to the client. Before send_to_client, code builds the preview from the contract in the database, not from the model’s summary:
{
"action": "send_to_client",
"recipient": "maria@client.com",
"clause": {
"id": "12",
"title": "Jurisdiction",
"text": "The courts of the Client's headquarters shall have exclusive jurisdiction.",
"proposed_effect": "remove clause 12 from the annex"
},
"model_reason": "jurisdiction clauses are unusual in contracts of this size"
}
The lawyer sees the text of clause 12, the effect (remove), and the destination email. She rejects with a reason: “client requires jurisdiction at headquarters”. The model gets the rejection as an observation and proposes something else, or stops. Without a preview of the real text, the “unusual” justification would have gone through, as it did in the pilot.
For Pix (Brazil’s instant payment system), the same idea: the preview shows the name the bank returns for the key, the amount, and the date. It does not show “pay the electricity bill”, which the model invented from an attached PDF.
flowchart TD
T(["1. Task"]):::io --> A(["2. Agent works"]):::llm
A --> C{"3. Review point?"}
C -->|no| A
C -->|yes| S[("4. Saved state")]:::store
S --> P["5. Preview with data and reason"]:::tool
P --> H((("6. Person: approve, edit, reject"))):::human
H --> R["7. Resume with the decision"]:::tool
R --> A
A --> OUT(["8. Result"]):::io
H --> L[("9. Decision record")]:::store
schema Review { decision: "approve" | "edit" | "reject", edited_action: any?, reason: string?, reviewer: string }
REVIEW_POLICY = {
"send_to_client": always,
"change_contract": always,
"schedule_payment": when(amount > 100_00),
"draft_note": never,
}
function step(state):
reply = llm_with_tools(SYSTEM, TOOLS, state) # 2
for call in reply.tool_calls:
if REVIEW_POLICY[call.name](call.args): # 3
checkpoint = state.save() # 4
preview = build_preview(call) # 5. system data
review = human.approve(preview) -> Review # 6. may take days
audit.log(call, preview, review) # 9
state = checkpoint.restore() # 7
if review.decision == "reject":
state.add(tool_result(call, "rejected: " + review.reason))
continue
if review.decision == "edit":
call = review.edited_action
state.add(execute(call))
return state
The smell this pattern treats head-on is Ungated Side Effect. The matching refactoring is Add Approval Before a Side Effect.
In bank support, the preview for a R$1,890 Pix (Brazil’s instant payment) needs the name the payment service returned for the key, the amount, and the date. “Pay the electricity bill, as the customer asked in the PDF” is the sentence a scam wants the person to stamp. The recipient name on the screen is what makes the lawyer, the operator, or the customer hesitate.
| Naive | Correct | |
|---|---|---|
| What to review | Everything the model produces | Only irreversible effects, outbound sends, or exceptions |
| Preview | The model’s justification | System data plus the concrete effect |
| “Yes” channel | The same conversation that reads the world | A button, app, or queue with identity |
| Wait | An HTTP request held open for two days | A checkpoint and a durable resume |
| Aspect | What happens | How it shows up in the trace |
|---|---|---|
| Latency | The task waits for the person: seconds to days | long human_review span |
| Cost | People’s time; model cost barely changes | reviews per day and average time per review |
| Failure: fatigue | Approval without reading | very short review time, approval rate near 100% |
| Failure: bad preview | The person approves something other than what they think | rejections and complaints after execution |
| Failure: endless wait | Nobody answers and the task sits idle | pending reviews past their deadline |
| Failure: broken resume | Saved state is not enough to continue | errors right after resume |
{ "action": "send_to_client", "proposal": "remove clause 12 (jurisdiction)", "expected_review": "reject", "reason": "client requires jurisdiction at headquarters" }
Put the preview the person sees into the eval, not only the action. If the preview omits the clause text, the “should reject” case does not test the real product.
human_review span with action, preview, decision, reviewer, and duration.Approval must come from a channel that neither the model nor outside content can write to. A “yes” typed in the same chat that reads third-party email does not prove the user approved. Show system data, not generated text, in the preview.
That is also the defense against the Chevrolet case: the model can “accept” an absurd price in the dialogue. The system of record (price table, cap, person) is what sells, not the chat turn.
| Pros | Cons |
|---|---|
| Expensive mistakes do not go out without a person seeing them | Latency and people’s cost |
| Clear accountability in regulated domains | Fatigue leads to rubber-stamping |
| Edits and rejections become improvement data | Needs persisted state for long waits |
| Lets you give the agent more capability safely | Does not scale to very high volume without sampling |
The tabs apply the review policy inside the run, with all three possible answers. Python and TypeScript pause inside the tool, in the same process. LangGraph pauses the graph with interrupt and a checkpointer, so the wait survives a restart.
Python. Use this when the loop is yours and approval is synchronous, such as a confirmation in the app.
import json
from dataclasses import dataclass
from typing import Callable, Literal
import anthropic
client = anthropic.Anthropic()
@dataclass
class Review:
decision: Literal["approve", "edit", "reject"]
edited_input: dict | None = None
reason: str = ""
reviewer: str = ""
POLICY: dict[str, Callable[[dict], bool]] = { # 3. when to review
"send_to_client": lambda a: True,
"schedule_payment": lambda a: a["amount_cents"] > 10_000,
"draft_note": lambda a: False,
}
def run_tool(block, execute, ask_human) -> dict:
args = block.input
if POLICY.get(block.name, lambda a: True)(args): # unknown tool: review it
preview = build_preview(block.name, args) # 5. system data, not model text
review: Review = ask_human(preview) # 6. blocks until the decision
audit.log(block.name, preview, review) # 9
if review.decision == "reject":
return {"type": "tool_result", "tool_use_id": block.id,
"content": f"Rejected by reviewer: {review.reason}", "is_error": True}
if review.decision == "edit":
args = review.edited_input
result = execute(block.name, args) # 7
return {"type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result)}What you gained: the policy lives in one place, and new tools get reviewed by default. What you lost: the wait is synchronous; for waits of hours or days, save the state and resume with a durable workflow.
TypeScript. Use this when the agent runs on the SDK runner and the gate lives inside the tool.
import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod";
import { z } from "zod";
type Review =
| { decision: "approve" }
| { decision: "edit"; edited: { amount_cents: number; key: string; date: string } }
| { decision: "reject"; reason: string };
export function schedulePaymentTool(askHuman: (preview: object) => Promise<Review>) {
return betaZodTool({
name: "schedule_payment",
description: "Schedules a payment. Always goes through user review above US$100.",
inputSchema: z.object({ key: z.string(), amount_cents: z.number().int().positive(), date: z.string() }),
run: async (args) => {
let input = args;
if (input.amount_cents > 10_000) {
// 5. preview with system data
const preview = { recipient: await bank.lookupKeyOwner(input.key), amount: input.amount_cents, date: input.date };
const review = await askHuman(preview); // 6
await audit.log("schedule_payment", preview, review); // 9
if (review.decision === "reject") return `Rejected by the user: ${review.reason}`;
if (review.decision === "edit") input = review.edited;
}
const result = await bank.schedulePayment(input); // 7
return JSON.stringify(result);
},
});
}What you gained: the gate lives in the tool, so no runner path skips it. What you lost: the policy is spread across tools; centralize it in one object when there are many.
Python + LangGraph. Use this when the person may answer minutes or days later, and the process may restart in the middle.
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
from typing_extensions import TypedDict
class State(TypedDict):
action: dict
decision: str
result: str
def review(state: State) -> dict:
# 5 and 6. the preview goes to a channel the model cannot write; the graph pauses here
decision = interrupt({"preview": state["action"], "reason": "payment above the limit"})
return {"decision": decision}
def act(state: State) -> dict:
# 7. resume with the decision
if state["decision"] != "approve":
return {"result": f"rejected: {state['decision']}"}
return {"result": bank.schedule_payment(state["action"])}
graph = StateGraph(State)
graph.add_node("review", review)
graph.add_node("act", act)
graph.add_edge(START, "review")
graph.add_edge("review", "act")
graph.add_edge("act", END)
# 4. state saved. MemorySaver dies with the process; in production use the database checkpointer.
app = graph.compile(checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "pay-8831"}}
# 3. enter this graph only if policy marked the action as a review point
app.invoke({"action": payment, "decision": "", "result": ""}, config)
# later, from the button in the app, not from a sentence in the chat:
app.invoke(Command(resume="approve"), config)bank and payment are yours. resume accepts "approve", "reject", or the edited payload. Record the decision in the same place as the Python tab (step 9).
What you gained: the wait survives a restart, and the yes does not travel in text the model reads. What you lost: a checkpointer to operate, and the rule for which action needs review, which stays in your code.
Checked against the LangGraph 1.x interrupts docs in October 2026.
| Library | In this pattern | Still yours |
|---|---|---|
| Hand-written loop | The Python tab, synchronous pause | The policy and the authenticated channel |
| Anthropic SDK | The gate inside the tool, on the runner | The same policy |
| LangGraph | interrupt plus a checkpointer |
Which actions pause, and the record of the decision |
| Temporal | The long wait as a human activity, if the rest of the flow is already a durable workflow | The preview the person sees |
Human-in-the-Loop is an AI design pattern for LLM apps and agents, in the Agent behavior group. Pause at defined points for a human to approve, edit, or reject.
In the real-world scenarios Personal finance assistant with approval, Support bot with human escalation, Health assistant with guardrails, and Legal assistant.