Skip to content
AI Full-Stack Wiki

Human-in-the-Loop Agents: Approvals, Pauses, and Resumable Runs

Pause an agent before risky actions, persist the run, ask a person to approve, edit, or reject, and resume with the right tool results, in TypeScript and Next.js.

Difficulty: Advanced17 min readUpdated
On this page· Decide what needs approval

Why this matters

Agents that only read are low risk. Agents that act (refund money, email customers, delete records, merge code) can do real damage when they misunderstand a request or are manipulated by a malicious document. Human-in-the-loop design puts a person in front of the actions that matter, without slowing down everything else. It is the difference between an agent you can demo and one you can deploy.

Decide what needs approval#

Not every tool needs a human. Classify each by what happens if it is wrong:

RiskExamplesPolicy
Read-onlySearch, look up an order, read a fileRun automatically
Reversible writesDraft a reply, add a label, create a ticketRun automatically; log it
Costly or externalRefunds, emails to customers, purchasesAsk a person first
DestructiveDelete data, change permissions, deployAsk a person, or do not expose at all

The policy lives in your code, never in the prompt. A prompt instruction such as "always ask before refunding" can be ignored or overridden by injected text; a check in the loop cannot.

The flow#

Approval before a risky actionThe model requests a refund; the app saves the pending call and notifies a reviewer; the reviewer approves; the app runs the refund and returns the result to the model, which confirms to the customer.pauseresumeModelAgentappPendingactionsReviewertool_userefund(order4821, $129)policy:refundsneedapprovalsave run +pendingcallapproval request withcontextapprove (or edit /reject)run refundwithidempotencykeytool_result:refundissuedfinalmessage tocustomer

Step 1 of 8

tool_use refund(order 4821, $129)

The model proposes the action. Nothing has happened yet.

Human-in-the-loop approval: when the agent requests a risky tool, the app pauses the run, stores the pending call, asks a person, and resumes with either the real result or a rejection the model can act on.
Show text description

A refund agent that may look things up freely but needs approval to move money. Participants, left to right: Model, Agent app, Pending actions, Reviewer.

  1. Model → Agent app: tool_use refund(order 4821, $129) · The model proposes the action. Nothing has happened yet.
  2. Agent app → Agent app: policy: refunds need approval · Code, not the model, decides which tools need approval: by tool, amount, or customer tier.
  3. Agent app → Pending actions: save run + pending call · The whole run is persisted, so the process can stop and resume hours later.
  4. Agent app → Reviewer: approval request with context (asynchronous) · Show what will happen, why the agent wants it, and the evidence: order, payments, conversation.
  5. Reviewer → Agent app: approve (or edit / reject) · The reviewer can approve, change the amount, or reject with a reason.
  6. Agent app → Agent app: run refund with idempotency key
  7. Agent app → Model: tool_result: refund issued · On rejection, the tool_result says so with the reason, and the model explains it to the customer.
  8. Model → Agent app: final message to customer

When the model requests a tool that needs approval, the loop stops before running it, saves the whole run (messages plus the pending call), and returns. Later, possibly hours later on another server, a person decides, and the run resumes from exactly that point. The model then receives one of three results:

  • Approved: the real result of running the tool.
  • Edited: the result of running it with the reviewer's changes (for example, a smaller refund), and a note saying what changed.
  • Rejected: an error result with the reviewer's reason, so the model can explain or try something else.

Implementation#

Tools with an approval policy#

lib/tools.ts
import type Anthropic from "@anthropic-ai/sdk";
 
export interface PolicyTool {
  definition: Anthropic.Tool;
  /** Return a reason when a person must approve this call, or null to run it automatically. */
  needsApproval(input: Record<string, unknown>): string | null;
  run(input: Record<string, unknown>): Promise<string>;
}
 
export const tools: PolicyTool[] = [
  {
    definition: {
      name: "get_order",
      description: "Look up an order by ID: status, amount in USD, and payments.",
      input_schema: { type: "object", properties: { order_id: { type: "string" } }, required: ["order_id"] },
    },
    needsApproval: () => null, // read-only
    run: async ({ order_id }) => JSON.stringify({ order_id, status: "delivered", amount: 129, payments: ["py_1", "py_2 (duplicate)"] }),
  },
  {
    definition: {
      name: "issue_refund",
      description: "Refund an amount in USD for an order. Requires human approval, so explain your reasoning in your message first.",
      input_schema: {
        type: "object",
        properties: { order_id: { type: "string" }, amount: { type: "number" }, reason: { type: "string" } },
        required: ["order_id", "amount", "reason"],
      },
    },
    needsApproval: ({ amount }) => `Refund of $${Number(amount).toFixed(2)} moves money`,
    run: async ({ order_id, amount }) => JSON.stringify({ refund_id: `re_${order_id}`, amount, status: "issued" }),
  },
];

Saving runs#

A run must survive between the pause and the decision, so it lives in a store. This in-memory version keeps the example self-contained; in production, use the Postgres tables from the durable execution article.

lib/run-store.ts
import type Anthropic from "@anthropic-ai/sdk";
 
export interface PendingCall {
  toolUseId: string;
  name: string;
  input: Record<string, unknown>;
  reason: string;
}
 
export interface RunState {
  id: string;
  userId: string;
  messages: Anthropic.MessageParam[];
  /** Results of calls from the paused turn that already ran (or were decided). */
  heldResults: Anthropic.ToolResultBlockParam[];
  pending: PendingCall[];
}
 
const runs = new Map<string, RunState>();
 
export const runStore = {
  async save(run: RunState) {
    runs.set(run.id, structuredClone(run));
  },
  async load(id: string) {
    const run = runs.get(id);
    return run ? structuredClone(run) : null;
  },
};

The loop that can pause#

lib/approval-agent.ts
import Anthropic from "@anthropic-ai/sdk";
import { runStore, type PendingCall, type RunState } from "./run-store.ts";
import { tools } from "./tools.ts";
 
const byName = new Map(tools.map((t) => [t.definition.name, t]));
 
export type RunOutcome =
  | { status: "done"; runId: string; answer: string }
  | { status: "awaiting_approval"; runId: string; pending: PendingCall[] };
 
export type Decision =
  | { toolUseId: string; action: "approve" }
  | { toolUseId: string; action: "edit"; input: Record<string, unknown>; note: string }
  | { toolUseId: string; action: "reject"; reason: string };
 
async function execute(name: string, toolUseId: string, input: Record<string, unknown>): Promise<Anthropic.ToolResultBlockParam> {
  try {
    const tool = byName.get(name);
    if (!tool) throw new Error(`Unknown tool ${name}`);
    return { type: "tool_result", tool_use_id: toolUseId, content: await tool.run(input) };
  } catch (error) {
    return { type: "tool_result", tool_use_id: toolUseId, content: String(error), is_error: true };
  }
}
 
async function loop(run: RunState, client: Anthropic, maxTurns = 10): Promise<RunOutcome> {
  for (let turn = 0; turn < maxTurns; turn++) {
    const message = await client.messages.create({
      model: "claude-opus-5-5",
      max_tokens: 8192,
      system: "You are a support agent for an online store. Investigate before acting, and explain refunds to the customer.",
      tools: tools.map((t) => t.definition),
      messages: run.messages,
    });
    run.messages.push({ role: "assistant", content: message.content });
 
    if (message.stop_reason !== "tool_use") {
      await runStore.save(run);
      return { status: "done", runId: run.id, answer: message.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("") };
    }
 
    // Run safe calls now; hold the ones that need a person.
    const results: Anthropic.ToolResultBlockParam[] = [];
    const pending: PendingCall[] = [];
    for (const call of message.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use")) {
      const input = (call.input ?? {}) as Record<string, unknown>;
      const reason = byName.get(call.name)?.needsApproval(input) ?? null;
      if (reason) pending.push({ toolUseId: call.id, name: call.name, input, reason });
      else results.push(await execute(call.name, call.id, input));
    }
 
    if (pending.length > 0) {
      // Pause: all results for this turn must go back together, so keep the finished ones too.
      run.heldResults = results;
      run.pending = pending;
      await runStore.save(run);
      return { status: "awaiting_approval", runId: run.id, pending };
    }
    run.messages.push({ role: "user", content: results });
  }
  throw new Error("Run exceeded the turn limit");
}
 
export async function startRun(goal: string, userId: string, client = new Anthropic()): Promise<RunOutcome> {
  const run: RunState = { id: crypto.randomUUID(), userId, messages: [{ role: "user", content: goal }], heldResults: [], pending: [] };
  return loop(run, client);
}
 
export async function resumeRun(runId: string, decisions: Decision[], client = new Anthropic()): Promise<RunOutcome> {
  const run = await runStore.load(runId);
  if (!run || run.pending.length === 0) throw new Error(`Run ${runId} is not awaiting approval`);
 
  const results = [...run.heldResults];
  for (const call of run.pending) {
    const decision = decisions.find((d) => d.toolUseId === call.toolUseId);
    if (!decision) throw new Error(`Missing decision for ${call.name} (${call.toolUseId})`);
    if (decision.action === "approve") {
      results.push(await execute(call.name, call.toolUseId, call.input));
    } else if (decision.action === "edit") {
      const result = await execute(call.name, call.toolUseId, decision.input);
      results.push({ ...result, content: `${String(result.content)}\n[A reviewer changed the request: ${decision.note}]` });
    } else {
      results.push({ type: "tool_result", tool_use_id: call.toolUseId, content: `Rejected by a reviewer: ${decision.reason}`, is_error: true });
    }
  }
 
  run.messages.push({ role: "user", content: results });
  run.heldResults = [];
  run.pending = [];
  return loop(run, client);
}

The subtle part is held results. If the model calls get_order and issue_refund in the same turn, get_order runs immediately, but its result cannot be sent yet: the API requires every tool_result for a turn in one user message. So it is saved with the run and sent together with the refund's result after the decision.

The approval endpoint#

In a Next.js app, the reviewer's UI posts decisions to a route handler. The handler checks that the caller may approve before resuming:

app/api/runs/[id]/decisions/route.ts
import { z } from "zod";
import { resumeRun } from "@/lib/approval-agent";
import { getReviewer } from "@/lib/auth";
 
const Decisions = z.array(
  z.discriminatedUnion("action", [
    z.object({ toolUseId: z.string(), action: z.literal("approve") }),
    z.object({ toolUseId: z.string(), action: z.literal("edit"), input: z.record(z.string(), z.unknown()), note: z.string().min(1) }),
    z.object({ toolUseId: z.string(), action: z.literal("reject"), reason: z.string().min(1) }),
  ]),
);
 
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const reviewer = await getReviewer(request);
  if (!reviewer) return Response.json({ error: "Not allowed to approve actions" }, { status: 403 });
 
  const parsed = Decisions.safeParse(await request.json());
  if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
 
  const { id } = await params;
  // Record who decided what before acting: approvals are an audit trail.
  console.info(JSON.stringify({ event: "run.decision", runId: id, reviewer: reviewer.id, decisions: parsed.data }));
  return Response.json(await resumeRun(id, parsed.data));
}
lib/auth.ts
// Replace with your session lookup. Only staff with the "approver" role may decide.
export async function getReviewer(request: Request): Promise<{ id: string } | null> {
  const token = request.headers.get("authorization");
  return token === `Bearer ${process.env.REVIEWER_TOKEN}` ? { id: "reviewer_1" } : null;
}

Trying it#

demo.ts
import { resumeRun, startRun } from "./lib/approval-agent.ts";
 
const first = await startRun("Order 4821 charged me twice. Please refund the duplicate.", "cus_1");
console.log(first.status);
 
if (first.status === "awaiting_approval") {
  for (const p of first.pending) console.log(`needs approval: ${p.name}(${JSON.stringify(p.input)}): ${p.reason}`);
 
  // Here a person would review in your UI. This demo approves the first pending call.
  const done = await resumeRun(first.runId, [{ toolUseId: first.pending[0]!.toolUseId, action: "approve" }]);
  console.log(done.status, done.status === "done" ? done.answer : "");
}

Testing the pause and resume#

The same scripted-client technique from the agent loop article tests the tricky parts without the API:

approval.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import type Anthropic from "@anthropic-ai/sdk";
import { resumeRun, startRun } from "./lib/approval-agent.ts";
 
function scripted(responses: Partial<Anthropic.Message>[]) {
  const requests: Anthropic.MessageCreateParams[] = [];
  const client = {
    messages: {
      create: async (params: Anthropic.MessageCreateParams) => {
        requests.push(structuredClone(params));
        return { usage: { input_tokens: 1, output_tokens: 1 }, ...responses.shift()! };
      },
    },
  } as unknown as Anthropic;
  return { client, requests };
}
 
const call = (id: string, name: string, input: unknown) =>
  ({ type: "tool_use", id, name, input, caller: { type: "direct" } }) as Anthropic.ToolUseBlock;
const turn = (...content: Anthropic.ContentBlock[]): Partial<Anthropic.Message> => ({ stop_reason: "tool_use", content });
const end = (text: string): Partial<Anthropic.Message> => ({
  stop_reason: "end_turn",
  content: [{ type: "text", text, citations: null } as Anthropic.TextBlock],
});
 
test("pauses before a refund and sends held results together on approval", async () => {
  const { client, requests } = scripted([
    turn(call("a", "get_order", { order_id: "4821" }), call("b", "issue_refund", { order_id: "4821", amount: 129, reason: "duplicate" })),
    end("Your refund is on its way."),
  ]);
 
  const paused = await startRun("refund please", "cus_1", client);
  assert.equal(paused.status, "awaiting_approval");
  assert.equal(requests.length, 1); // nothing else happened while waiting
 
  const done = await resumeRun(paused.runId, [{ toolUseId: "b", action: "approve" }], client);
  assert.equal(done.status, "done");
  const results = requests[1]!.messages.at(-1)!.content as Anthropic.ToolResultBlockParam[];
  assert.deepEqual(results.map((r) => r.tool_use_id), ["a", "b"]); // both results, one message
  assert.match(String(results[1]!.content), /issued/);
});
 
test("a rejection reaches the model as an error result with the reason", async () => {
  const { client, requests } = scripted([
    turn(call("b", "issue_refund", { order_id: "4821", amount: 500, reason: "angry customer" })),
    end("I can't issue that refund."),
  ]);
  const paused = await startRun("refund 500", "cus_1", client);
  await resumeRun(paused.runId, [{ toolUseId: "b", action: "reject", reason: "Exceeds order total" }], client);
  const [result] = requests[1]!.messages.at(-1)!.content as Anthropic.ToolResultBlockParam[];
  assert.equal(result!.is_error, true);
  assert.match(String(result!.content), /Exceeds order total/);
});
 
test("refuses to resume a run that is not waiting", async () => {
  const { client } = scripted([end("Hello!")]);
  const done = await startRun("hi", "cus_1", client);
  await assert.rejects(resumeRun(done.runId, [], client), /not awaiting approval/);
});

Designing the review experience#

The approval screen decides whether reviewers actually review or just click "Approve":

  • Show the action in plain terms: "Refund $129.00 to Ana Lopez for order 4821", not raw JSON.
  • Show the evidence: the conversation, the order, the payments the agent looked at.
  • Show the agent's reasoning, from its text before the tool call.
  • Allow edits, not just yes or no: a smaller amount, a corrected recipient.
  • Make rejection easy and require a reason; the model uses it.
  • Notify and expire: alert reviewers, and expire runs that wait too long, telling the customer what happens next.

Common mistakes#

  • Approval rules in the prompt. Enforce them in the loop.
  • Running the action, then asking. Approval must happen before the side effect.
  • Losing held results. Every tool_result for a turn goes in one message after the decision.
  • Rubber-stamp fatigue. If every action needs approval, reviewers stop reading. Approve only what matters, and auto-approve within safe limits (for example, refunds under $20).
  • No audit trail. Record who approved what, when, and what they saw.

Try it

Add an approval limit and an edit

  1. Change issue_refund's policy to auto-approve refunds of $20 or less, and add a test for both sides of the limit.
  2. Add a test where the reviewer edits a $129 refund down to $49 and check the model receives the note.
  3. Sketch the reviewer screen: what does a reviewer need to see to decide in under 30 seconds?

Check your understanding

Where should the rule 'refunds need approval' be enforced?
The model calls get_order and issue_refund in one turn; the refund needs approval. What happens to get_order's result?
A reviewer rejects a refund. What should the model receive?

Key takeaways

  • Classify tools by the cost of a mistake; require approval only for costly, external, or destructive actions.
  • Enforce approval in code before the side effect, persist the run, and resume with approve, edit, or reject results.
  • Hold finished results from the paused turn and send all results together after the decision.
  • Design the review screen for real review: plain-language actions, evidence, reasoning, edits, and an audit trail.

Next steps#

Finished this article? Track it to see your progress on the roadmap.