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

# Approvals in the loop

> Gate a tool call on a human decision, cap how many decisions an agent may ask for, show the model less than you record, and check the arguments it produced before they run.

## Tools a human has to approve

Mark the tools an agent should not use unattended. The run parks on a reviewer before the tool
runs, holding no worker while it waits:

```ts theme={null}
const issueRefund = tool({
  name: "issue-refund",
  description: "Issue a refund to the customer",
  inputSchema: { type: "object", properties: { amount: { type: "number" } } },
  requiresApproval: true,
  handler: (input) => refunds.issue(input),
});
```

`requiresApproval: true` raises the gate and says nothing else about it. **Say more with `approval`**,
and the same annotations a hand-written [`ctx.step.approval`](/ai/approvals) takes apply to the
tool's gate:

```ts theme={null}
const issueRefund = tool({
  name: "issue-refund",
  requiresApproval: true,
  approval: {
    risk: "high",
    summary: "Refund a customer for a duplicate charge",
    allow: ["approve", "reject"],
    timeout: "30m",
    onTimeout: "reject",
  },
  handler: (input) => refunds.issue(input),
});
```

<Warning>
  **Declare a `risk` on anything you would not want cleared by a credential.** A gate that states no
  risk is stored at the default, `medium`, and
  the [human-decision floor](/ai/approvals#who-may-decide-what) reads the stored risk - so a
  floor of `high` cannot refuse a gate that never claimed to be high.
</Warning>

The reviewer sees the input the model proposed and can change it before approving - the tool then
runs with **their** input, not the model's. A denial is not an error: it comes back to the model as
that tool's result, so the agent can say what it could not do instead of the run failing.

| Decision            | What runs                              | What the model gets                    |
| ------------------- | -------------------------------------- | -------------------------------------- |
| Approved            | The handler, with the decided input    | The handler's return value             |
| Approved with edits | The handler, with the reviewer's input | The handler's return value             |
| Denied              | Nothing                                | `{ approved: false, tool, decidedBy }` |

Each gate is its own durable step, so resuming the run replays the agent without asking anyone
twice. Both the approval and the tool call show up under their turn in the console, so a parked
agent reads as parked rather than as a tool that never finished.

### Gating some calls and not others

`requiresApproval` is one bit for the whole tool, so a `refund` tool marked `true` wakes someone for
a $2 refund and one marked `false` wakes nobody for a $50,000 one. `approval.if` and
`approval.minRisk` are the third answer: a condition on **this call**, written as data and evaluated
by Duraton.

```ts theme={null}
const issueRefund = tool({
  name: "issue-refund",
  inputSchema: { type: "object", properties: { amount: { type: "number" } } },
  requiresApproval: true,
  approval: {
    risk: "high",
    // Small refunds go through; anything above the limit waits for a person. And after
    // three turns the agent is looping, so gate whatever it reaches for next.
    if: "args.amount > 1000.0 || iteration > 3",
  },
  handler: (input) => refunds.issue(input),
});
```

The expression reads four names and no others - `tool`, `args`, `risk` and `iteration` - and every
JSON number in `args` reaches it as a double. A rule that cannot be evaluated raises the gate rather
than skipping it, and one that declines completes the step as `decidedBy: "system:rule"` with no
approval for anyone to answer. The full environment, the failure modes and when this beats a
hand-written `if` are on [Approvals](/ai/approvals#deciding-whether-to-gate-at-all).

### Which rule applies to a tool

A rule can be declared in two places: on the tool, and on the agent as the default for every tool
that has not answered for itself.

```ts theme={null}
await agent(ctx, "support", {
  model: "claude-opus-4-8",
  prompt: `Resolve this ticket: ${ctx.event.data.ticket}`,
  tools: [searchPolicy, issueRefund],
  mcpServers: [{ name: "orders", transport: { kind: "http", url: process.env.ORDERS_MCP_URL } }],
  // Anything that did not answer for itself gates, and gates like this.
  approval: { risk: "high", summary: "A tool this agent was not told how to treat", timeout: "1h" },
  maxIterations: 6,
});
```

The two levels resolve in one order, and `requiresApproval` is **tri-state** on purpose - absent,
`true` and `false` are three different answers rather than a boolean with a default:

| The tool says             | The agent has a default | The gate                                                    |
| ------------------------- | ----------------------- | ----------------------------------------------------------- |
| `requiresApproval: false` | either way              | **none.** An opt-out is an answer, and it beats the default |
| `approval: { ... }`       | either way              | raised, under the **tool's own** rule                       |
| `requiresApproval: true`  | yes                     | raised, under the **agent's default**                       |
| `requiresApproval: true`  | no                      | raised, with nothing said about it                          |
| nothing                   | yes                     | raised, under the **agent's default**                       |
| nothing                   | no                      | none                                                        |

Two rows are worth reading twice. `requiresApproval: true` on a tool the agent has a default for
takes that default's annotations rather than erasing them: `true` asks for a gate, it does not claim
there is nothing to say about one. And a tool's own `approval` **replaces** the default rather than
merging with it - a rule is one policy, so a tool that states `risk` and no `timeout` has no timeout,
whatever the default said.

<Warning>
  A tool with **no opinion at all** inherits the default. That is the point: `requiresApproval: false`
  is how a tool says it never waits for anyone, and saying nothing is not the same as saying that.
</Warning>

The default is also what covers an [MCP server's tools](/agent-kit/providers-and-mcp#attaching-an-external-mcp-server) that the
server's own predicate says nothing about. Those tools are attached here and discovered at runtime,
so there is no declaration above to annotate and no list to enumerate ahead of time.

## Capping the decisions an agent asks for

`maxApprovals` is a ceiling on how many human decisions one agent may ask for. When a turn's tool
calls would take it past the ceiling, the agent stops instead of running them, and returns
`stopReason: "approval-budget"`:

```ts theme={null}
const result = await agent(ctx, "support", {
  model: "claude-opus-4-8",
  prompt: `Resolve this ticket: ${ctx.event.data.ticket}`,
  tools: [searchPolicy, issueRefund],
  maxIterations: 12,
  maxApprovals: 3,
});

if (result.stopReason === "approval-budget") {
  return { outcome: "handed-off", reason: "the agent asked for more sign-off than it was allowed" };
}
```

Omitted, there is no ceiling. Set one where an agent could plausibly keep reaching for a gated tool:
`maxIterations` bounds how long it runs, and this bounds how much of a person's attention it can
spend doing so. Handle the stop like any other terminal reason - the agent returns rather than
throwing, so the workflow decides what a run that ran out of sign-off does next.

## Showing the model less than you record

A tool that returns a thousand rows costs a thousand rows of context on every turn after it. Give
it a `toModelOutput` and the model reads the summary while the durable step keeps the whole thing:

```ts theme={null}
const listOrders = tool({
  name: "list-orders",
  description: "Every order for a customer",
  outputSchema: { type: "object", properties: { orders: { type: "array" } } },
  toModelOutput: (output) => ({
    count: output.orders.length,
    ids: output.orders.slice(0, 5).map((o) => o.id),
  }),
  handler: (input) => orders.listFor(input.customerId),
});
```

The projection is not a truncation you can never undo. The tool's step still holds all thousand
orders, so the run's history, a replay, and anything reading the timeline all see the full result -
only the model's context was spent on the summary.

<Warning>
  `toModelOutput` must be pure. A replay re-projects the result the tool already returned rather
  than calling the tool again, so a projection that reads the clock or a counter makes a replayed
  turn disagree with the original.
</Warning>

It runs only on a result your tool actually produced. A denial from an approval gate is the loop
telling the model about its own gate, so it reaches the model unprojected.

## Checking the model's tool arguments

The arguments a tool is called with are written by the model. Pass a guardrail and they are checked
against the tool's own `inputSchema` before the handler ever sees them; a refusal comes back to the
model as that tool's result, so it can correct itself.

```ts theme={null}
import { createSchemaGuardrail } from "@duraton/sdk";

const result = await agent(ctx, "agent", {
  model: "claude-opus-4-8",
  prompt: `Resolve this ticket: ${ctx.event.data.ticket}`,
  tools: [searchPolicy],
  maxIterations: 6,
  guardrails: [createSchemaGuardrail()],
});
```

The verdict is its own durable step, so a replay reads what was decided instead of deciding again.
[Guardrails](/ai/guardrails) covers the placements, the actions, and how to write your own.
