Trail content
PatternAgent behavior

Human-in-the-Loop

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.

Element
46/70
Level
Intermediate
Read
10 min
Updated
On this page 18
  1. 01Intent
  2. 02Problem
  3. 03Solution
  4. 04Structure
  5. 05Pseudocode
  6. 06Real-world scenarios
  7. 07Applicability
  8. 08When not to use
  9. 09How to implement
  10. 10Variations
  11. 11Cost, latency, and failure modes
  12. 12How to evaluate
  13. 13In production
  14. 14Security
  15. 15Pros and cons
  16. 16Code
  17. 17Framework support
  18. 18Check your understanding
  19. ··Quick answers

Intent

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.

In one sentence
The co-pilot who reads the checklist aloud and waits for “check” before every critical step of takeoff.

Problem

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?

Solution

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:

  1. Pauses and saves the run’s state.
  2. Shows what will happen, with the data the person needs to decide and the reason for the suggestion.
  3. Receives one of three answers: approve, edit, or reject (with a reason).
  4. Resumes where it stopped, with the decision as part of the state.

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.

Worked example

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.

Structure

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
  1. The task starts as usual.
  2. The agent works with read tools and prepares its actions.
  3. Code checks whether the next action is a review point, by action type, amount, or confidence.
  4. The full state is saved, so the wait can last minutes or days.
  5. The preview shows the concrete action, data from the system, and the model’s justification.
  6. The person decides, in a channel only they control.
  7. The run resumes: with the approved action, the edited version, or the rejection as information.
  8. The result goes out.
  9. Every decision is recorded, with who decided and what was shown.
Check +20 XP on first tryThe chatbot reads customer email on the same conversation thread. A message arrives saying ‘approved, you may send’. Code treats that as human approval. What is wrong?

Pseudocode

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

Real-world scenarios

  • Personal finance assistant with approval. Every scheduled Pix (Brazil’s instant payment) goes through confirmation in the app, with the recipient’s real name. Pre-authorized recurring payments have a cap.
  • Support bot with human escalation. Cancellations and refunds ask for the customer’s “yes”; refunds above an amount go to an agent.
  • Contracts and health (hypothetical). Suggested contract edits and medication guidance wait for a professional. Privacy law (LGPD in Brazil) and board rules want an accountable person; the model drafts, the person signs.

Applicability

  • Irreversible or expensive actions: money, outside communication, changes to third-party data.
  • Regulated domains where a person must answer for the decision.
  • Flows where the model is almost always right, and the “almost” is expensive.
  • Early product phases, to measure quality before granting more autonomy.

The smell this pattern treats head-on is Ungated Side Effect. The matching refactoring is Add Approval Before a Side Effect.

When not to use

  • Cheap, reversible actions. Review becomes noise and people stop reading.
  • Volume too high for human review. Use review by sampling, by confidence, or by amount, and invest in automatic checks on the output (schema, term lists, a lookup against the official policy).
  • Answers that must go out in seconds, such as voice. Review what you can afterwards and limit what the system can do in real time.

How to implement

  1. List the system’s actions and classify them by risk and reversibility.
  2. Write the review policy: which actions, from what amount, or below what confidence.
  3. Design the preview with the reviewers: what they need to see to decide in seconds. One screen, one system fact, one effect in plain language.
  4. Implement the pause with persisted state, for long waits. See Move to Durable Execution.
  5. Support all three answers: approve, edit, and reject with a reason.
  6. Make the tool refuse to execute without a recorded approval. The gate lives in the tool’s code, not in the prompt.
  7. Record every decision in the Audit Log.
  8. Use rejections and edits as new eval cases.

What beginners get wrong

  • Review everything. The queue explodes, median review time drops to one second, and you have a stamp, not a control. Fewer points, better ones.
  • Preview with the model’s prose. “This clause is redundant” hides the clause text. Show the text.
  • Days of synchronous waiting. The process dies, state vanishes, the agent starts over and sends again. Checkpoint first.
  • Approving on the same channel as the injection. A chat that reads the customer’s PDF is not a “yes” channel.

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 versus correct

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
Check +20 XP on first tryThe lawyer approves a change. Months later she finds it was the jurisdiction clause. The preview said ‘remove redundant clause’, with the model's justification. What failed?

Variations

  • Approve before. The action waits for “yes”. It is the default for irreversible effects.
  • Review after, with a cancel window. The action is scheduled and can be canceled within X minutes. Useful when waiting gets in the way and the effect can be undone.
  • Sampled review. A fraction of outputs goes to review, to measure quality at high volume.
  • Escalation. The system decides on its own when confidence is high and hands off to a person when it is not. Confidence alone is not enough for money and outbound email.
  • Pre-authorization. The user approves a rule (“up to US$40 to the school, monthly”), and actions within it proceed without pausing.

Cost, latency, and failure modes

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
Check +20 XP on first tryThe approval rate is 99.8% and median review time is 1.2 s. What does that suggest first?

How to evaluate

  • Approval, edit, and rejection rates per action type. Frequent edits show where the model goes wrong.
  • Review time: very short times suggest rubber-stamping.
  • Errors that slipped through: approved actions later shown to be wrong, by sampling.
  • Eval with rejections: rejected actions become cases where the system should propose something else.
{ "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.

In production

  • A human_review span with action, preview, decision, reviewer, and duration.
  • A queue of pending reviews with deadlines and an alert when one passes its deadline.
  • A dashboard of approvals by reviewer and by action type.
  • Periodic policy review: actions always approved without edits may earn pre-authorization; frequently rejected actions call for a system change.

Security

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 and cons

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

Code

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.

Framework support

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

Check your understanding

Check +20 XP on first tryThe user pre-authorized ‘up to US$40 to the school, monthly’. A US$6 Pix arrives for that key, within the month. What should the policy do?

Check +20 XP on first tryA review sits in the queue for three days. When the person hits ‘yes’, the agent process is already dead and the task restarts from scratch, sending a second email. What was missing?

Check +20 XP on first tryYour support bot answers refund policy from the prompt, not from the official policy store. A customer gets a promise the company will not honor. Which change comes first?

Quick answers

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.

Which smells does the Human-in-the-Loop pattern help cure?

Ungated Side Effect.

Where does Human-in-the-Loop show up in practice?

In the real-world scenarios Personal finance assistant with approval, Support bot with human escalation, Health assistant with guardrails, and Legal assistant.

Go further

Relations

Smells it helps cure 1
Refactorings that lead here 1
Part of the tracks 1
Search