Skip to main content

Human-in-the-loop overview

Human-in-the-loop (HITL) pauses an agent before a tool call runs so a person can review it. The reviewer approves the call, edits its arguments, rejects it with feedback, or answers in place of the tool. Use it for actions that are hard to undo or that need sign-off, such as deleting records, sending email, or writing files.

You add review with humanInTheLoopMiddleware in LangChain, or with the interruptOn parameter in Deep Agents. The middleware pauses in one of two modes:

  • Per-call: One pause for each tool call that needs review.
  • Batched: One pause per model turn, covering every tool call in that turn. Batched mode is the current default.

Note

Per-call mode requires langchain>=1.5.18. Deep Agents uses batched mode.

Follow an approval from pause to resume

Every approval follows the same steps in either mode:

  1. The model proposes one or more tool calls.
  2. The middleware checks each call against your interruptOn policy. Calls that need review pause the agent with an interrupt.
  3. The checkpointer saves the agent's state, so the run can stop and resume later, even in another process.
  4. Your application shows the pending calls to a reviewer and collects a decision for each one.
  5. You resume the agent with a new Command({ resume }). The middleware applies each decision, and the agent continues.

A decision is one of four types:

Decision TypeDescriptionExample Use Case
✅ approveExecute the tool with the original arguments as proposed by the agent.Send an email draft exactly as written
✏️ editModify the tool arguments before execution.Change the recipient before sending an email
❌ rejectSkip executing this tool call entirely and return rejection feedback to the agent.Deny file deletion and explain why
💬 respondReturn the human's message directly as a synthetic tool result, skipping execution, for "ask user" style tools.Answer an "ask_user" prompt with a direct reply

The examples on this page use an agent with three tools. write_file allows every decision type. execute_sql allows only approve and reject. read_data runs without review. In one turn, the model calls all three.

Review each tool call separately

In per-call mode, each tool call that needs review pauses on its own as it starts:

sequenceDiagram
    participant App as Your app
    participant Agent
    participant Tools

    App->>Agent: invoke(user message)
    Agent->>Tools: write_file, execute_sql, read_data
    Tools-->>Agent: read_data result (not gated, runs right away)
    Tools-->>App: interrupt for write_file
    Tools-->>App: interrupt for execute_sql
    App->>Agent: resume {write_file ID: approve, execute_sql ID: reject}
    Note over Agent: Each answer is checked against its response_schema before it is saved
    Agent->>Tools: run write_file
    Agent-->>App: final result

Each interrupt value describes one call and has type: "tool_approval". The execute_sql interrupt looks like this:

{
  type: "tool_approval",
  tool_call_id: "call_sql",
  name: "execute_sql",
  args: { query: "DELETE FROM orders WHERE created_at < '2025-01-01'" },
  description: "Tool execution requires approval\n\nTool: execute_sql\nArgs: {\n  \"query\": \"DELETE FROM orders WHERE created_at < '2025-01-01'\"\n}",
}

Each interrupt also carries a response_schema, a JSON schema of the answers that call accepts. For execute_sql, the schema allows approve and reject. For write_file, it also allows respond and edit. The edit branch includes write_file's own argument schema. Clients can render a form from the schema instead of hardcoding the decision types. The LangChain guide shows the schema for each decision type.

You answer each interrupt with one decision, keyed by the interrupt's ID:

new Command({
  resume: {
    [writeFileInterrupt.id]: { type: "approve" },
    [executeSqlInterrupt.id]: { type: "reject", message: "Archive the rows before deleting them." },
  },
});

LangGraph rejects an invalid answer before saving it, so the reviewer can correct it and answer again. Interrupts you do not answer stay pending, which lets a reviewer answer them one at a time.

Review a whole turn at once

In batched mode, the middleware pauses once after the model responds, before any tool runs. read_data waits for the review too, even though it is not gated. One interrupt lists every gated call in actionRequests, and each call's allowed decisions in a separate reviewConfigs list:

{
  actionRequests: [
    { name: "write_file", args: { path: "orders_archive.csv", content: "id,total\n1,20\n" }, description: "..." },
    { name: "execute_sql", args: { query: "DELETE FROM orders WHERE created_at < '2025-01-01'" }, description: "..." },
  ],
  reviewConfigs: [
    { actionName: "write_file", allowedDecisions: ["approve", "edit", "reject", "respond"] },
    { actionName: "execute_sql", allowedDecisions: ["approve", "reject"] },
  ],
}

You answer with one list of decisions, in the same order as actionRequests:

new Command({
  resume: {
    decisions: [
      { type: "approve" },
      { type: "reject", message: "Archive the rows before deleting them." },
    ],
  },
});

The interrupt has no response_schema. The middleware checks each decision only when it applies it, after LangGraph has saved the answer.

Compare batched-mode problems

Batched mode matches answers to calls by position, and checks each answer only after saving it. Each row shows a problem the example agent hits in batched mode, and how per-call mode avoids it:

In batched modeWhat happensIn per-call mode
Decisions match calls by positionA reviewer means to approve write_file and reject execute_sql, but sends [reject, approve]. The rejection goes to write_file.Each decision is keyed by its call's interrupt ID.
Allowed decisions live in reviewConfigsClients pair two lists by position and hardcode a button for each decision type.Each interrupt's response_schema lists its decisions and edit arguments.
An answer is saved before it is checkedOne decision for two calls throws Error: Number of human decisions (1) does not match number of hanging tool calls (2). The corrected answer then fails with the same error, so the thread stays stuck.The answer is checked first. Fix it and answer again.
Edited arguments are not checkedAn edit to write_file without content still runs the tool, and the model receives an error for a call it never made.The edit is rejected with edited_action.args.content Invalid input: expected string, received undefined.
One reject stops the whole turnApproving write_file and rejecting execute_sql runs neither tool, nor the ungated read_data. The model receives only the rejection.Each call runs or is rejected on its own.

Choose an interrupt mode

Per-callBatched (default)
When it pausesAs each gated tool call startsAfter the model responds, before any tool runs
Interrupts per turnOne per gated tool callOne for the whole turn
Ungated calls in the turnRun right awayWait for the review
AnswerOne decision per interrupt, keyed by interrupt IDA list of decisions, matched by position
Best forNew agents, one approval card or message per call, forms built from the schemaApproval UIs that already read actionRequests

Use per-call mode for new agents. LangChain plans to make it the default in the next major release.

Keep batched mode while your approval UI reads the batched format, such as one built with the useStream approval pattern.

Per-call mode also changes how middleware order and multi-answer resumes behave. Before you switch, read Order middleware for per-call review and Resume each call by ID.

See also