> ## Documentation Index
> Fetch the complete documentation index at: https://anvil.thatdevguy.in/llms.txt
> Use this file to discover all available pages before exploring further.

# Human-in-the-Loop: Pause and Approve Agent Actions

> Suspend an agent run mid-execution, persist its state, and resume it after a human reviews and approves or rejects a pending action.

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

<Steps>
  <Step title="Tool suspends the run">
    A tool calls `meta.requestApproval(payload)` (or throws `ApprovalRequiredError` directly), passing a payload that describes what needs approval.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

## 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:

```ts theme={null}
import type { AgentTool } from 'anvil-sdk/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:

```ts theme={null}
import { ApprovalRequiredError } from 'anvil-sdk/agent';

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

### ApprovalRequiredError fields

<ResponseField name="callId" type="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.
</ResponseField>

<ResponseField name="payload" type="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.
</ResponseField>

## Wiring the checkpoint store

HITL requires durable checkpointing. Pass a `checkpoint` option to `runAgent` or `streamAgent` so the suspended state is persisted:

```ts theme={null}
import { runAgent } from 'anvil-sdk/agent';
import { SqliteStateStore } from 'anvil-sdk/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:

```ts theme={null}
import { resumeAgent } from 'anvil-sdk/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:

<ParamField body="checkpoint.store" type="StateStore" required>
  The same `StateStore` used when the run was originally started.
</ParamField>

<ParamField body="checkpoint.runId" type="string" required>
  The stable run identifier used to locate the saved checkpoint.
</ParamField>

<ParamField body="approval" type="unknown" optional>
  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.
</ParamField>

## 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`:

```ts theme={null}
import { defineAgent, toolPolicy } from 'anvil-sdk/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

<Note>
  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.
</Note>

## 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:

```ts theme={null}
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:

```ts theme={null}
// 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.

<Note>
  `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.
</Note>
