Skip to main content
Human-in-the-loop (HITL) lets you pause an agent run at a critical decision point — before issuing a refund, sending an email, or executing a destructive operation — persist the full run state, and resume execution only after a human reviewer approves. The run is durably checkpointed in this suspended state, so the review can happen seconds or days later without losing any context.

How the HITL flow works

1

Tool suspends the run

A tool calls meta.requestApproval(payload) (or throws ApprovalRequiredError directly), passing a payload that describes what needs approval.
2

Run is checkpointed

The agent runtime catches the error, saves an AgentCheckpoint with status: 'suspended' and the pending payload, and emits a suspended event to the response stream.
3

Reviewer inspects and decides

The /_anvil dashboard shows the pending run in the HITL queue with its payload. The reviewer approves or rejects from the UI — or your application calls resumeAgent programmatically.
4

Run resumes

resumeAgent loads the checkpoint, injects the approval decision as the tool result, and continues the agent loop from where it left off. The suspended tool is never re-executed.

Triggering HITL from a tool

Use meta.requestApproval inside a tool’s execute function. It is a typed shorthand that throws ApprovalRequiredError with the supplied payload:
import type { AgentTool } from 'anvil/agent';

const issueRefund: AgentTool = {
  name: 'issueRefund',
  description: 'Issue a refund for an order.',
  sideEffect: true,
  execute: async ({ orderId, amount }, meta) => {
    // Pause and ask for human approval before touching money.
    meta.requestApproval({ action: 'refund', orderId, amount });
    // Code below never runs until the run is resumed with an approval.
    return await refundProvider.issue(orderId, amount);
  },
};
Or throw ApprovalRequiredError directly if you need more control:
import { ApprovalRequiredError } from 'anvil/agent';

throw new ApprovalRequiredError(meta.callId, {
  action: 'refund',
  orderId,
  amount,
  requestedBy: ctx.user.email,
});

ApprovalRequiredError fields

callId
string
The tool call ID that triggered the suspension. Anvil uses this to inject the approval result back into the message history when the run resumes.
payload
unknown
Arbitrary data describing the pending action. This is what appears in the /_anvil dashboard and what your approval handler receives to make its decision. Keep it human-readable — it is the reviewer’s primary signal.

Wiring the checkpoint store

HITL requires durable checkpointing. Pass a checkpoint option to runAgent or streamAgent so the suspended state is persisted:
import { runAgent } from 'anvil/agent';
import { SqliteStateStore } from 'anvil/store';

const stateStore = await SqliteStateStore.open('.anvil/state.db');
const runId = `refund:${orderId}:${Date.now()}`;

const result = await runAgent({
  client,
  messages,
  tools: [lookupOrder, issueRefund],
  checkpoint: { store: stateStore, runId },
});

if (result.suspended) {
  console.log('Run suspended. Waiting for approval:', result.suspended.callId);
  // The /_anvil dashboard now shows this run in the approval queue.
}

Resuming with an approval decision

Call resumeAgent with the runId and the reviewer’s approval value. The approval is injected as the result of the suspended tool call, and the agent loop continues:
import { resumeAgent } from 'anvil/agent';

// Called by your approval webhook or dashboard action handler:
const result = await resumeAgent({
  client,
  tools: [lookupOrder, issueRefund],
  checkpoint: { store: stateStore, runId: 'refund:order-99:1718000000' },
  approval: {
    approved: true,
    reviewer: 'alice@example.com',
    note: 'Verified with customer. Proceed.',
  },
});

console.log('Run completed:', result.text);
If approval is undefined or carries a rejection value, the tool should inspect it in its execute body and handle accordingly — for example by returning an error string instead of calling the payment provider.

resumeAgent options

resumeAgent accepts all the same options as runAgent, minus messages, approvals, and resumeState (which come from the checkpoint), plus two additional fields:
checkpoint.store
StateStore
required
The same StateStore used when the run was originally started.
checkpoint.runId
string
required
The stable run identifier used to locate the saved checkpoint.
approval
unknown
The human reviewer’s decision. Injected as the result of the tool call that was pending when the run suspended. The receiving tool can inspect this value in its execute body on the next invocation.

Using toolPolicy for zero-code HITL

You can require approval for any tool without modifying the tool’s execute function. Add toolPolicy to guardrails with the tool name in requireApproval:
import { defineAgent, toolPolicy } from 'anvil/agent';

export const post = defineAgent({
  client,
  guardrails: [
    toolPolicy({
      requireApproval: ['issueRefund', 'deleteAccount'],
    }),
  ],
  tools: [lookupOrder, issueRefund, deleteAccount],
});
When the model calls issueRefund or deleteAccount, the guardrail returns an approve decision before the tool’s execute function runs. The runtime suspends the run exactly as if the tool had thrown ApprovalRequiredError. This is useful for adding approval requirements to third-party tools you cannot modify.

The HITL approval queue in /_anvil

All runs with status: 'suspended' appear in the /_anvil dashboard approval queue. Each entry shows:
  • The runId and the trace name
  • The pending.payload — the description of the action needing approval
  • The accumulated cost and token usage at suspension time
  • Controls to approve or reject, which call resumeAgent on your behalf
The dashboard HITL queue reads suspended checkpoints from the StateStore. Make sure your dashboardMiddleware and your agent routes share the same SQLite file (.anvil/state.db) so the queue reflects live data.

Handling rejection

When a reviewer rejects a pending action, call resumeAgent and pass a rejection value as approval. On resume, the tool’s execute function runs again from the top with the suspended call fenced — the agent loop supplies the approval value as the tool result directly. Design your tool to check the approval value before performing the side effect:
const issueRefund: AgentTool = {
  name: 'issueRefund',
  description: 'Issue a refund for an order.',
  sideEffect: true,
  execute: async ({ orderId, amount }, meta) => {
    // First invocation: suspend and wait for a reviewer.
    // meta.requestApproval always throws — execution does not continue past this line.
    meta.requestApproval({ action: 'refund', orderId, amount });
  },
};
On resume, pass the reviewer’s decision as approval to resumeAgent. The approval value is injected as the tool result seen by the model. The model then generates a new response based on that result — for example, explaining to the user that the refund was rejected:
// Reviewer rejected — pass a descriptive rejection object.
await resumeAgent({
  client,
  tools: [lookupOrder, issueRefund],
  checkpoint: { store: stateStore, runId },
  approval: { approved: false, reason: 'Amount exceeds policy limit.' },
});
The model receives { approved: false, reason: '...' } as the tool result and crafts an appropriate response for the user.
meta.requestApproval() is typed as never — it always throws ApprovalRequiredError and never returns. Any code written after it in the same execute function body is unreachable and will not run on the initial invocation or on resume.