Tutorial: Build an Expense Approval Agent with Human Review
Build an agent that pauses before paying or declining, persists the run across processes, and resumes on a reviewer's decision, with expiry and an audit log.
On this page· What you will build
Why this matters
Human-in-the-loop agents shows how a run pauses before a risky tool call and resumes with a person's decision. In a real company that decision arrives hours later, from someone else, in another process, and it has to hold up in an audit. This tutorial builds an expense agent for that world: it reads claims, checks the policy and the payment ledger, and proposes to pay or decline. Anything above a threshold, and every decline, waits for a reviewer. Paused runs live on disk, reviewers decide from a separate command, nobody can approve their own claim, approvals expire, and every action is logged.
What you will build#
$ npm run agent -- c-102
[c-102-mvfsst6g] paused, waiting for a reviewer:
#1 reimburse {"claim_id":"c-102","amount":250,"note":"Capped at the $250 lodging limit"} (Reimbursements above $75 need approval)
$ npm run review -- approve c-102-mvfsst6g --as ben
error: You cannot review your own claim
$ npm run review -- approve c-102-mvfsst6g --as dana
[c-102-mvfsst6g] done: Reimbursed $250 to ben for c-102.- Policy tools: three reads that run freely, and two writes with a rule for when a person must decide.
- A run store on disk: paused runs, an append-only audit log, and a per-run lock.
- An agent loop that runs safe calls, holds the rest, and saves the run when it pauses.
- Decisions (approve, edit, reject) with separation of duties, edits that can only lower an amount, and expiry.
- Two command-line tools: one starts the agent; the other lists, decides, and sweeps expired approvals.
Hover or tap a component to see what it does.
Show text descriptionHide text description
The agent proposes; code decides what needs a person; a person decides; the log remembers.
- Agent loop: get_claim, get_policy, and find_similar_claims run without asking anyone.
- Approval rule: Each write tool says when a person must decide, and why.
- Paused run: Conversation, held results, and pending calls; saved with write-then-rename.
- Reviewer: Approve, edit (lower only), or reject; never on their own claim. Late decisions expire.
- Ledger: Never more than claimed, never twice: enforced in the tool, even after approval.
- Audit log: Started, paused, decision with who, executed with who, done.
You need Node.js 22.18 or later and an Anthropic API key.
1. Set up the project#
mkdir expense-approvals && cd expense-approvals
npm init -y
npm pkg set type=module
npm install @anthropic-ai/sdk zod
npm install -D typescript @types/node
mkdir src test data
npm pkg set scripts.agent="node --env-file=.env src/agent-cli.ts" scripts.review="node --env-file=.env src/review-cli.ts" \
scripts.test="node --test" scripts.typecheck="tsc"Copy the tsconfig.json from Foundations Tutorial 1 and add your key to .env.
2. The claims#
[
{ "id": "c-101", "employee": "ana", "category": "taxi", "amount": 42, "date": "2026-10-12", "merchant": "City Cabs", "description": "Airport to client office" },
{ "id": "c-102", "employee": "ben", "category": "lodging", "amount": 480, "date": "2026-10-13", "merchant": "Harbor Hotel", "description": "One night, client workshop" },
{ "id": "c-103", "employee": "ana", "category": "taxi", "amount": 42, "date": "2026-10-12", "merchant": "City Cabs", "description": "Airport to client office" },
{ "id": "c-104", "employee": "chen", "category": "lodging", "amount": 180, "date": "2026-10-14", "merchant": "Station Inn", "description": "One night, conference" }
]Each claim exercises one path: a small claim paid automatically, a large one capped by policy, a duplicate of the first, and one that nobody reviews in time.
3. Tools with an approval rule#
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import type Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
export interface Claim {
id: string;
employee: string;
category: string;
amount: number;
date: string;
merchant: string;
description: string;
}
// Per-category limits in USD. Above the limit, at most the limit is reimbursed.
export const POLICY: Record<string, { limit: number; note?: string }> = {
taxi: { limit: 60 },
lodging: { limit: 250, note: "per night" },
meals: { limit: 75, note: "per person; team meals need a manager's approval" },
};
export const AUTO_APPROVE_UP_TO = 75; // reimbursements above this wait for a person
export interface ToolContext {
dataDir: string;
stateDir: string;
}
export interface PolicyTool {
definition: Anthropic.Tool;
kind: "read" | "write"; // writes are recorded in the audit log
/** A reason a person must approve this call, or null to run it automatically. */
needsApproval(input: Record<string, unknown>): string | null;
run(input: Record<string, unknown>, ctx: ToolContext): Promise<string>;
}
export async function loadClaims(dataDir: string): Promise<Claim[]> {
return JSON.parse(await readFile(path.join(dataDir, "claims.json"), "utf8")) as Claim[];
}
// What has actually been paid out: the record the duplicate check reads.
async function readLedger(stateDir: string): Promise<{ claimId: string; employee: string; date: string; claimed: number; paid: number }[]> {
try {
return JSON.parse(await readFile(path.join(stateDir, "ledger.json"), "utf8"));
} catch {
return [];
}
}
const schema = (shape: z.ZodRawShape) => {
const { $schema: _ignored, ...json } = z.toJSONSchema(z.object(shape));
return json as Anthropic.Tool.InputSchema;
};
const ReimburseInput = z.object({ claim_id: z.string(), amount: z.number().positive(), note: z.string() });
export const tools: PolicyTool[] = [
{
definition: { name: "get_claim", description: "Read an expense claim by ID.", input_schema: schema({ claim_id: z.string() }) },
kind: "read",
needsApproval: () => null,
run: async ({ claim_id }, ctx) => {
const claim = (await loadClaims(ctx.dataDir)).find((c) => c.id === claim_id);
if (!claim) throw new Error(`No claim ${String(claim_id)}`);
return JSON.stringify(claim);
},
},
{
definition: { name: "get_policy", description: "Read the reimbursement limit for a category.", input_schema: schema({ category: z.string() }) },
kind: "read",
needsApproval: () => null,
run: async ({ category }) => JSON.stringify(POLICY[String(category)] ?? { limit: 0, note: "category not covered by the policy" }),
},
{
definition: {
name: "find_similar_claims",
description: "Find reimbursements already paid to an employee for a claim with the same amount and date. Use it to catch duplicates.",
input_schema: schema({ employee: z.string(), amount: z.number(), date: z.string() }),
},
kind: "read",
needsApproval: () => null,
run: async ({ employee, amount, date }, ctx) =>
JSON.stringify((await readLedger(ctx.stateDir)).filter((e) => e.employee === employee && e.claimed === amount && e.date === date)),
},
{
definition: {
name: "reimburse",
description: `Pay an amount in USD for a claim. Amounts above $${AUTO_APPROVE_UP_TO} wait for a person, so explain your reasoning in your message first.`,
input_schema: schema(ReimburseInput.shape),
},
kind: "write",
needsApproval: ({ amount }) => (Number(amount) > AUTO_APPROVE_UP_TO ? `Reimbursements above $${AUTO_APPROVE_UP_TO} need approval` : null),
run: async (input, ctx) => {
const { claim_id, amount, note } = ReimburseInput.parse(input);
const claim = (await loadClaims(ctx.dataDir)).find((c) => c.id === claim_id);
if (!claim) throw new Error(`No claim ${claim_id}`);
if (amount > claim.amount) throw new Error(`Cannot pay $${amount}: the claim is for $${claim.amount}`);
const ledger = await readLedger(ctx.stateDir);
if (ledger.some((e) => e.claimId === claim_id)) return JSON.stringify({ claim_id, status: "already_paid" }); // idempotent
ledger.push({ claimId: claim_id, employee: claim.employee, date: claim.date, claimed: claim.amount, paid: amount });
await writeFile(path.join(ctx.stateDir, "ledger.json"), JSON.stringify(ledger, null, 2));
return JSON.stringify({ claim_id, paid: amount, note, status: "paid" });
},
},
{
definition: {
name: "decline",
description: "Decline a claim, for example a duplicate. Always waits for a person.",
input_schema: schema({ claim_id: z.string(), reason: z.string() }),
},
kind: "write",
needsApproval: () => "Declining an employee's claim needs a person's sign-off",
run: async ({ claim_id, reason }) => JSON.stringify({ claim_id, status: "declined", reason }),
},
];The approval rule lives in code, next to each tool, and returns a reason a reviewer will read. Two more safeguards sit inside reimburse itself, so they hold even after a person approves: it never pays more than was claimed, and it never pays the same claim twice.
4. Runs, audit, and locks on disk#
import { appendFile, mkdir, open, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import type Anthropic from "@anthropic-ai/sdk";
export type Decision =
| { action: "approve" }
| { action: "edit"; amount: number; note: string }
| { action: "reject"; reason: string }
| { action: "expired" };
export interface PendingCall {
toolUseId: string;
name: string;
input: Record<string, unknown>;
reason: string; // why a person must decide
requestedAt: number;
expiresAt: number;
decision?: Decision & { by: string; at: number };
}
export interface RunState {
id: string;
claimId: string;
status: "running" | "awaiting_approval" | "done" | "failed";
messages: Anthropic.MessageParam[];
heldResults: Anthropic.ToolResultBlockParam[]; // results from the paused turn that already ran
pending: PendingCall[];
answer?: string;
}
/** Runs, the audit log, and locks, all under one state directory. */
export function createStore(stateDir: string) {
const runsDir = path.join(stateDir, "runs");
const runFile = (id: string) => path.join(runsDir, `${id}.json`);
/** One process at a time per run: two reviewers cannot both resume it. */
async function lock(id: string): Promise<{ release: () => Promise<void> }> {
await mkdir(runsDir, { recursive: true });
const file = `${runFile(id)}.lock`;
try {
const handle = await open(file, "wx"); // creating the file is taking the lock
await handle.write(String(process.pid));
await handle.close();
return { release: () => rm(file, { force: true }) };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
const pid = Number(await readFile(file, "utf8"));
let alive = true;
try {
process.kill(pid, 0); // signal 0 only checks that the process exists
} catch {
alive = false;
}
if (alive) throw new Error(`run ${id} is being handled by process ${pid}; try again in a moment`);
await rm(file, { force: true }); // its owner crashed
return lock(id);
}
return {
stateDir,
async save(run: RunState) {
await mkdir(runsDir, { recursive: true });
// Write to a temporary file, then rename: a crash never leaves a half-written run.
await writeFile(`${runFile(run.id)}.tmp`, JSON.stringify(run, null, 2));
await rename(`${runFile(run.id)}.tmp`, runFile(run.id));
},
async load(id: string): Promise<RunState> {
return JSON.parse(await readFile(runFile(id), "utf8")) as RunState;
},
async list(): Promise<RunState[]> {
await mkdir(runsDir, { recursive: true });
const files = (await readdir(runsDir)).filter((f) => f.endsWith(".json")).sort();
return Promise.all(files.map(async (f) => JSON.parse(await readFile(path.join(runsDir, f), "utf8")) as RunState));
},
/** Append-only: who did what, when. Never rewritten. */
async audit(runId: string, event: string, details: Record<string, unknown> = {}) {
await mkdir(stateDir, { recursive: true });
await appendFile(path.join(stateDir, "audit.jsonl"), `${JSON.stringify({ at: new Date().toISOString(), run: runId, event, ...details })}\n`);
},
lock,
};
}
export type Store = ReturnType<typeof createStore>;A paused run is everything needed to continue it: the conversation, the results that already ran in the paused turn, and the pending calls with their deadlines. Saving with a write-then-rename means a crash leaves either the old file or the new one, never half of one. The lock is the same pattern as in the durable digest tutorial: created with the wx flag, and taken over only when its owner process is gone.
5. The loop that pauses, and the decisions that resume it#
import Anthropic from "@anthropic-ai/sdk";
import type { Decision, PendingCall, RunState, Store } from "./store.ts";
import { loadClaims, type ToolContext, tools } from "./tools.ts";
const byName = new Map(tools.map((t) => [t.definition.name, t]));
const SYSTEM =
"You process employee expense claims. Read the claim, check the policy and for duplicates, then reimburse or decline. " +
"Before calling reimburse or decline, explain your reasoning in one or two sentences: a reviewer will read it.";
export interface AgentEnv {
store: Store;
ctx: ToolContext;
client?: Anthropic;
approvalTtlMs?: number; // how long a pending approval stays valid
}
export type Outcome = { status: "done"; runId: string; answer: string } | { status: "awaiting_approval"; runId: string; pending: PendingCall[] };
async function execute(env: AgentEnv, runId: string, call: { id: string; name: string; input: Record<string, unknown> }, actor: string): Promise<Anthropic.ToolResultBlockParam> {
try {
const tool = byName.get(call.name);
if (!tool) throw new Error(`Unknown tool ${call.name}`);
const content = await tool.run(call.input, env.ctx);
if (tool.kind === "write") await env.store.audit(runId, "executed", { tool: call.name, input: call.input, by: actor, result: content });
return { type: "tool_result", tool_use_id: call.id, content };
} catch (error) {
return { type: "tool_result", tool_use_id: call.id, content: error instanceof Error ? error.message : String(error), is_error: true };
}
}
async function loop(env: AgentEnv, run: RunState, maxTurns = 8): Promise<Outcome> {
const client = env.client ?? new Anthropic();
for (let turn = 0; turn < maxTurns; turn++) {
const message = await client.messages.create({
model: "claude-opus-5-5",
max_tokens: 8192,
output_config: { effort: "low" },
system: SYSTEM,
tools: tools.map((t) => t.definition),
messages: run.messages,
});
run.messages.push({ role: "assistant", content: message.content });
if (message.stop_reason !== "tool_use") {
run.status = "done";
run.answer = message.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
await env.store.save(run);
await env.store.audit(run.id, "done", { answer: run.answer });
return { status: "done", runId: run.id, answer: run.answer };
}
// Safe calls run now; calls that need a person are held.
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) {
results.push(await execute(env, run.id, { id: call.id, name: call.name, input }, "agent (no approval needed)"));
continue;
}
const now = Date.now();
pending.push({ toolUseId: call.id, name: call.name, input, reason, requestedAt: now, expiresAt: now + (env.approvalTtlMs ?? 24 * 3_600_000) });
}
if (pending.length > 0) {
// Pause. All results of a turn go back together, so keep the ones that already ran.
run.status = "awaiting_approval";
run.heldResults = results;
run.pending = pending;
await env.store.save(run);
for (const p of pending) await env.store.audit(run.id, "paused", { tool: p.name, input: p.input, reason: p.reason });
return { status: "awaiting_approval", runId: run.id, pending };
}
run.messages.push({ role: "user", content: results });
}
run.status = "failed";
await env.store.save(run);
await env.store.audit(run.id, "failed", { reason: "turn limit" });
throw new Error(`Run ${run.id} exceeded the turn limit`);
}
export async function startRun(env: AgentEnv, claimId: string): Promise<Outcome> {
const run: RunState = {
id: `${claimId}-${Date.now().toString(36)}`,
claimId,
status: "running",
messages: [{ role: "user", content: `Process expense claim ${claimId}.` }],
heldResults: [],
pending: [],
};
await env.store.audit(run.id, "started", { claim: claimId });
return loop(env, run);
}
/** Turn every decided call into its tool result, then continue the loop where it paused. */
async function resume(env: AgentEnv, run: RunState): Promise<Outcome> {
const results = [...run.heldResults];
for (const call of run.pending) {
const d = call.decision!;
const id = call.toolUseId;
if (d.action === "approve") results.push(await execute(env, run.id, { id, name: call.name, input: call.input }, d.by));
else if (d.action === "edit") {
const r = await execute(env, run.id, { id, name: call.name, input: { ...call.input, amount: d.amount } }, d.by);
results.push({ ...r, content: `${String(r.content)}\n[A reviewer changed the amount to $${d.amount}: ${d.note}]` });
} else if (d.action === "reject") {
// Not an error: it is the reviewer's answer, and the agent should report it, not retry.
results.push({ type: "tool_result", tool_use_id: id, content: `A reviewer rejected this: ${d.reason}. Do not retry; tell the employee.` });
} else {
results.push({ type: "tool_result", tool_use_id: id, content: "This request expired without a decision. Do not retry; tell the employee to resubmit the claim." });
}
}
run.messages.push({ role: "user", content: results });
run.status = "running";
run.heldResults = [];
run.pending = [];
return loop(env, run);
}
/**
* Record one reviewer's decision on one pending call. When every pending call of the run
* has a decision, the run resumes in this process.
*/
export async function decide(env: AgentEnv, runId: string, callIndex: number, decision: Exclude<Decision, { action: "expired" }>, reviewer: string): Promise<Outcome> {
const lock = await env.store.lock(runId);
try {
const run = await env.store.load(runId);
const call = run.pending[callIndex];
if (run.status !== "awaiting_approval" || !call) throw new Error(`Run ${runId} has no pending call #${callIndex + 1}`);
if (call.decision) throw new Error(`Already decided by ${call.decision.by}: ${call.decision.action}`);
const claim = (await loadClaims(env.ctx.dataDir)).find((c) => c.id === run.claimId);
if (claim?.employee === reviewer) throw new Error("You cannot review your own claim");
if (decision.action === "edit" && !(decision.amount > 0 && decision.amount <= Number(call.input.amount))) {
throw new Error(`An edit can only lower the amount (requested: $${String(call.input.amount)})`);
}
const expired = Date.now() > call.expiresAt;
call.decision = expired ? { action: "expired", by: "system", at: Date.now() } : { ...decision, by: reviewer, at: Date.now() };
const { at: _at, ...decided } = call.decision;
await env.store.audit(runId, "decision", { tool: call.name, ...decided, ...(expired ? { attemptedBy: reviewer } : {}) });
if (run.pending.some((p) => !p.decision)) {
await env.store.save(run);
return { status: "awaiting_approval", runId, pending: run.pending.filter((p) => !p.decision) };
}
return await resume(env, run);
} finally {
await lock.release();
}
}
/** Expire every overdue approval and resume the runs they were blocking. */
export async function expireOverdue(env: AgentEnv): Promise<Outcome[]> {
const outcomes: Outcome[] = [];
for (const run of await env.store.list()) {
if (run.status !== "awaiting_approval" || !run.pending.some((p) => !p.decision && Date.now() > p.expiresAt)) continue;
const lock = await env.store.lock(run.id);
try {
const fresh = await env.store.load(run.id); // re-read under the lock
for (const p of fresh.pending) {
if (p.decision || Date.now() <= p.expiresAt) continue;
p.decision = { action: "expired", by: "system", at: Date.now() };
await env.store.audit(run.id, "decision", { tool: p.name, action: "expired", by: "system" });
}
if (fresh.pending.every((p) => p.decision)) outcomes.push(await resume(env, fresh));
else await env.store.save(fresh);
} finally {
await lock.release();
}
}
return outcomes;
}What decide enforces, in order:
- One decider at a time. The run's lock means two reviewers clicking at once cannot both resume it; the second gets an error, and once the run moves on, "no pending call".
- Separation of duties. The reviewer cannot be the employee who filed the claim. In a web app the reviewer's identity comes from the session, never from the request body.
- Edits only lower the amount. A reviewer can reduce a payment, but raising one would be a new request that needs its own approval.
- Expiry. A decision that arrives after the deadline is recorded as
expired, whoever sent it. An approval given yesterday for yesterday's facts should not move money today.
And how each decision reaches the model: approve and edit run the tool (an edit adds a note the model can relay); reject and expired become plain results saying what happened and not to retry. They are answers, not errors, so the model reports them instead of trying again.
6. The command-line tools#
import type { AgentEnv, Outcome } from "./agent.ts";
import { createStore } from "./store.ts";
export const env: AgentEnv = {
store: createStore("state"),
ctx: { dataDir: "data", stateDir: "state" },
approvalTtlMs: Number(process.env.APPROVAL_TTL_MINUTES ?? 24 * 60) * 60_000,
};
export function report(outcome: Outcome) {
if (outcome.status === "done") {
console.log(`[${outcome.runId}] done: ${outcome.answer}`);
return;
}
console.log(`[${outcome.runId}] paused, waiting for a reviewer:`);
outcome.pending.forEach((p, i) => console.log(` #${i + 1} ${p.name} ${JSON.stringify(p.input)} (${p.reason})`));
}import { startRun } from "./agent.ts";
import { env, report } from "./env.ts";
// Usage: npm run agent -- <claim-id>. The process ends when the run finishes or pauses.
const claimId = process.argv[2];
if (!claimId) {
console.error("usage: npm run agent -- <claim-id>");
process.exit(2);
}
report(await startRun(env, claimId));import { parseArgs } from "node:util";
import type Anthropic from "@anthropic-ai/sdk";
import { decide, expireOverdue } from "./agent.ts";
import { env, report } from "./env.ts";
const { positionals, values } = parseArgs({
allowPositionals: true,
options: {
as: { type: "string" }, // the reviewer; in a web app this comes from the session
call: { type: "string", default: "1" },
amount: { type: "string" },
note: { type: "string", default: "" },
reason: { type: "string", default: "" },
},
});
const [command = "list", runId] = positionals;
// The agent's own explanation, written just before it asked for the action.
const explanation = (messages: Anthropic.MessageParam[]) => {
const last = messages.at(-1);
return last?.role === "assistant" && Array.isArray(last.content)
? last.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join(" ")
: "";
};
try {
if (command === "list") {
const waiting = (await env.store.list()).filter((r) => r.status === "awaiting_approval");
if (waiting.length === 0) console.log("Nothing is waiting for review.");
for (const run of waiting) {
console.log(`${run.id} (claim ${run.claimId})\n agent: ${explanation(run.messages)}`);
run.pending.forEach((p, i) => {
const left = Math.round((p.expiresAt - Date.now()) / 60_000);
const state = p.decision ? `decided: ${p.decision.action} by ${p.decision.by}` : left > 0 ? `expires in ${left} min` : "EXPIRED";
console.log(` #${i + 1} ${p.name} ${JSON.stringify(p.input)}\n why a person: ${p.reason}; ${state}`);
});
}
} else if (command === "expire") {
const outcomes = await expireOverdue(env);
if (outcomes.length === 0) console.log("No overdue approvals.");
outcomes.forEach(report);
} else if (["approve", "edit", "reject"].includes(command) && runId && values.as) {
const decision =
command === "approve" ? { action: "approve" as const }
: command === "edit" ? { action: "edit" as const, amount: Number(values.amount), note: values.note }
: { action: "reject" as const, reason: values.reason || "no reason given" };
report(await decide(env, runId, Number(values.call) - 1, decision, values.as));
} else {
console.error(
"usage: npm run review list what is waiting\n" +
" npm run review -- approve <run-id> --as <name>\n" +
" npm run review -- edit <run-id> --amount <usd> --note <text> --as <name>\n" +
" npm run review -- reject <run-id> --reason <text> --as <name>\n" +
" npm run review -- expire expire overdue approvals and resume their runs",
);
process.exit(2);
}
} catch (error) {
console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}The review list shows what a reviewer needs to decide quickly: the proposed action with its exact input, the agent's reasoning, why a person is needed, and the time left. In production, expire runs on a schedule, and the list becomes a page with buttons.
7. Run the whole flow#
Start the agent on all four claims. Run the last one with a zero-minute approval window, so it expires at once:
npm run agent -- c-101
npm run agent -- c-102
npm run agent -- c-103
APPROVAL_TTL_MINUTES=0 npm run agent -- c-104[c-101-mvfsssed] done: Reimbursed $42 to ana for c-101.
[c-102-mvfsst6g] paused, waiting for a reviewer:
#1 reimburse {"claim_id":"c-102","amount":250,"note":"Capped at the $250 lodging limit"} (Reimbursements above $75 need approval)
[c-103-mvfsstvo] paused, waiting for a reviewer:
#1 decline {"claim_id":"c-103","reason":"Duplicate: $42 for 2026-10-12 was already reimbursed (c-101)"} (Declining an employee's claim needs a person's sign-off)
[c-104-mvfssul0] paused, waiting for a reviewer:
#1 reimburse {"claim_id":"c-104","amount":180,"note":"Within the $250 lodging limit"} (Reimbursements above $75 need approval)Each agent process exited. The paused runs are files in state/runs/. Now be the reviewer:
npm run reviewc-102-mvfsst6g (claim c-102)
agent: The claim is $480, above the $250 lodging limit (per night), so I propose reimbursing $250.
#1 reimburse {"claim_id":"c-102","amount":250,"note":"Capped at the $250 lodging limit"}
why a person: Reimbursements above $75 need approval; expires in 1440 min
c-103-mvfsstvo (claim c-103)
agent: ana was already reimbursed $42 for this taxi on 2026-10-12 (c-101). This looks like a duplicate, so I propose declining it.
#1 decline {"claim_id":"c-103","reason":"Duplicate: $42 for 2026-10-12 was already reimbursed (c-101)"}
why a person: Declining an employee's claim needs a person's sign-off; expires in 1440 min
c-104-mvfssul0 (claim c-104)
agent: $180 is within the $250 lodging limit and there is no duplicate, so I propose reimbursing it in full.
#1 reimburse {"claim_id":"c-104","amount":180,"note":"Within the $250 lodging limit"}
why a person: Reimbursements above $75 need approval; EXPIREDDecide, using your own run IDs:
npm run review -- approve c-102-mvfsst6g --as ben # error: You cannot review your own claim
npm run review -- approve c-102-mvfsst6g --as dana # [c-102-mvfsst6g] done: Reimbursed $250 to ben for c-102.
npm run review -- approve c-102-mvfsst6g --as erin # error: Run c-102-mvfsst6g has no pending call #1
npm run review -- approve c-103-mvfsstvo --as dana # [c-103-mvfsstvo] done: Declined c-103. Duplicate: ...
npm run review -- expire # [c-104-mvfssul0] done: The approval for c-104 expired. chen needs to resubmit the claim.Then read state/audit.jsonl: every start, pause, decision (with who made it), executed write (with who authorized it and the result), and finish, one JSON object per line.
{"at":"2026-10-19T22:00:27.966Z","run":"c-101-mvfsssed","event":"executed","tool":"reimburse","input":{...},"by":"agent (no approval needed)",...}
{"at":"2026-10-19T22:00:32.488Z","run":"c-102-mvfsst6g","event":"decision","tool":"reimburse","action":"approve","by":"dana"}
{"at":"2026-10-19T22:00:32.493Z","run":"c-102-mvfsst6g","event":"executed","tool":"reimburse","input":{...},"by":"dana",...}8. Test the pause and every decision#
Each test builds a fresh store over the same directory for each step, which is what a separate process would do.
import assert from "node:assert/strict";
import { copyFile, mkdtemp, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "node:test";
import Anthropic from "@anthropic-ai/sdk";
import { type AgentEnv, decide, expireOverdue, startRun } from "../src/agent.ts";
import { createStore } from "../src/store.ts";
type Block = Record<string, unknown>;
/** A real client whose HTTP layer replays scripted responses in order and records requests. */
function scriptedClient(responses: Block[][]) {
const requests: Anthropic.MessageCreateParams[] = [];
const client = new Anthropic({
apiKey: "test-key",
maxRetries: 0,
fetch: async (_url, init) => {
requests.push(JSON.parse(String(init?.body)) as Anthropic.MessageCreateParams);
const content = responses.shift() ?? [{ type: "text", text: "(script exhausted)" }];
return Response.json({
id: `msg_${requests.length}`,
type: "message",
role: "assistant",
model: "claude-opus-5-5",
stop_reason: content.some((b) => b.type === "tool_use") ? "tool_use" : "end_turn",
stop_sequence: null,
usage: { input_tokens: 100, output_tokens: 20 },
content,
});
},
});
return { client, requests };
}
const say = (t: string) => ({ type: "text", text: t });
const use = (id: string, name: string, input: unknown) => ({ type: "tool_use", id, name, input });
/** A fresh state directory, and a function that builds an env on it, as a new process would. */
async function setup(approvalTtlMs = 60_000) {
const dir = await mkdtemp(path.join(tmpdir(), "approvals-"));
await copyFile("data/claims.json", path.join(dir, "claims.json"));
const stateDir = path.join(dir, "state");
const makeEnv = (client: Anthropic): AgentEnv => ({ store: createStore(stateDir), ctx: { dataDir: dir, stateDir }, client, approvalTtlMs });
const auditEvents = async () =>
(await readFile(path.join(stateDir, "audit.jsonl"), "utf8")).trim().split("\n").map((l) => (JSON.parse(l) as { event: string }).event);
return { stateDir, makeEnv, auditEvents };
}
test("a small reimbursement runs without a pause", async () => {
const { makeEnv, auditEvents } = await setup();
const { client } = scriptedClient([[use("t1", "reimburse", { claim_id: "c-101", amount: 42, note: "Within policy" })], [say("Paid $42.")]]);
const outcome = await startRun(makeEnv(client), "c-101");
assert.equal(outcome.status, "done");
assert.deepEqual(await auditEvents(), ["started", "executed", "done"]);
});
test("a large reimbursement pauses, survives a restart, and resumes on approval", async () => {
const { makeEnv, stateDir, auditEvents } = await setup();
const { client, requests } = scriptedClient([
[say("Over the lodging limit, so I propose the $250 cap."), use("t1", "get_policy", { category: "lodging" }), use("t2", "reimburse", { claim_id: "c-102", amount: 250, note: "Capped" })],
[say("Reimbursed $250.")],
]);
const paused = await startRun(makeEnv(client), "c-102");
assert.equal(paused.status, "awaiting_approval");
// A different process: a new store over the same directory.
const done = await decide(makeEnv(client), paused.runId, 0, { action: "approve" }, "dana");
assert.equal(done.status, "done");
// Both results of the paused turn went back together, in one message.
const results = requests[1]!.messages.at(-1)!.content as Anthropic.ToolResultBlockParam[];
assert.deepEqual(results.map((r) => r.tool_use_id), ["t1", "t2"]);
assert.match(String(results[1]!.content), /"paid":250/);
assert.match(await readFile(path.join(stateDir, "ledger.json"), "utf8"), /c-102/);
assert.deepEqual(await auditEvents(), ["started", "paused", "decision", "executed", "done"]);
});
test("a rejection is the reviewer's answer, not an error, and nothing is paid", async () => {
const { makeEnv, stateDir } = await setup();
const { client, requests } = scriptedClient([[use("t1", "reimburse", { claim_id: "c-104", amount: 150, note: "Conference night" })], [say("Told Chen.")]]);
const paused = await startRun(makeEnv(client), "c-104");
await decide(makeEnv(client), paused.runId, 0, { action: "reject", reason: "conference hotels must be booked through travel" }, "dana");
const result = (requests[1]!.messages.at(-1)!.content as Anthropic.ToolResultBlockParam[])[0]!;
assert.equal(result.is_error, undefined);
assert.match(String(result.content), /rejected this: conference hotels/);
await assert.rejects(readFile(path.join(stateDir, "ledger.json")));
});
test("reviewers can lower an amount but not raise it, and cannot review their own claim", async () => {
const { makeEnv } = await setup();
const { client, requests } = scriptedClient([[use("t1", "reimburse", { claim_id: "c-104", amount: 150, note: "Conference night" })], [say("Paid $75.")]]);
const paused = await startRun(makeEnv(client), "c-104");
await assert.rejects(decide(makeEnv(client), paused.runId, 0, { action: "approve" }, "chen"), /your own claim/);
await assert.rejects(decide(makeEnv(client), paused.runId, 0, { action: "edit", amount: 500, note: "" }, "dana"), /only lower/);
await decide(makeEnv(client), paused.runId, 0, { action: "edit", amount: 75, note: "use the corporate rate" }, "dana");
assert.match(String((requests[1]!.messages.at(-1)!.content as Anthropic.ToolResultBlockParam[])[0]!.content), /"paid":75[\s\S]*changed the amount to \$75/);
await assert.rejects(decide(makeEnv(client), paused.runId, 0, { action: "approve" }, "erin"), /no pending call/); // already resolved
});
test("an approval that expired cannot be approved; the sweep resumes the run", async () => {
const { makeEnv, stateDir } = await setup(0); // approvals expire immediately
const { client, requests } = scriptedClient([
[use("t1", "reimburse", { claim_id: "c-102", amount: 250, note: "Capped" })],
[say("Asked Ben to resubmit.")],
[use("t2", "reimburse", { claim_id: "c-104", amount: 150, note: "Conference night" })],
[say("Asked Chen to resubmit.")],
]);
const first = await startRun(makeEnv(client), "c-102");
await new Promise((r) => setTimeout(r, 5));
const late = await decide(makeEnv(client), first.runId, 0, { action: "approve" }, "dana");
assert.equal(late.status, "done");
assert.match(String((requests[1]!.messages.at(-1)!.content as Anthropic.ToolResultBlockParam[])[0]!.content), /expired without a decision/);
await assert.rejects(readFile(path.join(stateDir, "ledger.json"))); // nothing was paid
await startRun(makeEnv(client), "c-104");
await new Promise((r) => setTimeout(r, 5));
const swept = await expireOverdue(makeEnv(client));
assert.deepEqual(swept.map((o) => o.status), ["done"]);
});npm test # 5 passing, no API callsThe second test checks the subtle rule from the article: when a turn mixes a safe call (get_policy) with one that needs approval, the safe result is held and sent back in the same message as the approved one, after the pause.
Common mistakes#
- Keeping paused runs in memory. A deploy or a crash loses every pending approval. Persist the run, including results already computed in the paused turn.
- Trusting the reviewer named in the request. Identity comes from authentication. Then check separation of duties: nobody approves their own claim.
- Approvals that never expire. Facts change; an old approval can move money for a situation that no longer exists. Give every approval a deadline and sweep expired ones.
- Turning a rejection into an error. The model then tends to retry. Return it as a plain result that says what happened and what to do instead.
- Edits that bypass the policy. Let reviewers lower amounts, not raise them, and keep hard limits inside the tool where an approval cannot skip them.
- No audit trail. For anything that moves money, record who proposed it, who decided, and what ran, in an append-only log.
Try it
Extend the approvals
- Add a second approval level: amounts above $500 need two different reviewers, recorded as two decisions on the same call.
- Turn
review-cli.tsinto a Next.js page with Approve, Edit, and Reject buttons, taking the reviewer's identity from the session as in the article. - Send a notification when a run pauses, and a reminder one hour before its approval expires.
- Write
npm run audit -- c-102that reconstructs one run's full history, including the agent's explanations, from the audit log and the run file.
Check your understanding
Key takeaways
- Decide in code which tool calls need a person, and give each pause a reason the reviewer can read.
- Persist paused runs with the results already computed, so decisions can arrive hours later in another process.
- Enforce one decider at a time, separation of duties, edits that only reduce risk, and expiry.
- Feed rejections and expirations back as plain results, and record every proposal, decision, and action in an append-only audit log.
Next steps#
Finished this article? Track it to see your progress on the roadmap.
Related articles
- Human-in-the-Loop Agents: Approvals, Pauses, and Resumable RunsPause 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.
- Tutorial: Build a Crash-Proof AI Digest Job with Durable StepsBuild a weekly AI digest job that survives crashes: a step journal, a lock that detects dead workers, crash injection, resume, and an email that is never sent twice.
- Security for LLM Apps: Prompt Injection, Data Leaks, and Agent RisksThreat-model an AI feature, defend against direct and indirect prompt injection, block exfiltration and SSRF, scope tools, and protect secrets and personal data.