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

> Stop your agent before a risky action and wait for a person - the run parks holding no worker, then resumes from exactly that checkpoint.

Some steps should not run until a person signs off - issuing a refund, deleting records, sending a
bulk email. An **approval** is a step that parks the run on that decision: the run suspends in
`needs_attention`, keeps its checkpoint, and holds no runner until someone (or an agent) approves or
denies it. The decision resumes the run from exactly where it paused.

<Frame>
  <img src="https://mintcdn.com/duraton-950ff1ca/C6sntRFXqHtJQroE/images/approvals.png?fit=max&auto=format&n=C6sntRFXqHtJQroE&q=85&s=736ced6f6519201b49808ed19bf7c1e8" alt="An approval awaiting a human decision in the console" width="3200" height="2000" data-path="images/approvals.png" />
</Frame>

```ts theme={null}
const decision = await ctx.step.approval<RefundArgs>("refund-gate", {
  tool: "issue-refund",
  args: { orderId, amount, currency: "usd", reason: "billing_error" },
  risk: "high",
  summary: `Refund ${amount} to ${orderId} for a duplicate charge`,
  policy: "tools.issue-refund -> require approval",
  escalatesTo: "#support-leads",
  timeout: "30m",
});

if (decision.status === "denied") return { outcome: "denied" };

// decision.args are the effective args - the decider's edits when changed, else the proposed ones.
const refund = await ctx.step.run("issue-refund", () => issue(decision.args));
```

This is the same durable-suspension machinery as [`waitForEvent`](/core/steps) and
sleep - a parked run costs nothing while it waits and survives restarts - but what it waits on is a
human decision rather than an event or a timer.

## The request

`ctx.step.approval(id, request)` takes a stable step `id` and the request below. Only `tool` is
required; the rest annotate the decision for whoever reviews it.

| Property | Type | Default | Description |
| - | - | - | - |
| `tool` | `string` | required | The action awaiting sign-off, e.g. `"issue-refund"`. |
| `args` | `A` | none | The proposed input for that action. The decider sees it and may edit it before approving. |
| `risk` | `"low"` \| `"medium"` \| `"high"` | none | The declared risk level, shown on the approval in the inbox. |
| `summary` | `string` | none | A one-line human description of what is being asked. |
| `policy` | `string` | none | The rule that required an approval here, recorded on the request. |
| `context` | `string` | none | Any further background for the reviewer. |
| `allow` | `HitlDecision[]` | every decision | Which decisions this reviewer may take. `["approve", "reject"]` offers no edit affordance, and the engine refuses a verb the request did not offer. |
| `escalatesTo` | `string` | none | The escalation target named on the approval once its timeout elapses. |
| `timeout` | `string` \| `number` | no deadline | The deadline (`"30m"`, or ms). What reaching it does is `onTimeout`; with no timeout the approval waits indefinitely. |
| `onTimeout` | `"escalate"` \| `"approve"` \| `"reject"` \| `"fail"` | `"escalate"` | What the deadline does to an undecided approval. Only `escalate` leaves it open - see [Timeouts](#timeouts-and-escalation). Ignored without a `timeout`: there is no deadline to reach. |
| `if` | `string` | always gate | A CEL condition deciding whether the gate is raised at all - see [Deciding whether to gate at all](#deciding-whether-to-gate-at-all). |
| `minRisk` | `"low"` \| `"medium"` \| `"high"` | no floor | A floor on this request's own `risk`: gate only at or above it. |
| `iteration` | `number` | `0` | The agent loop turn this call was made on, readable from `if`. Set for you inside an agent; `0` on a hand-written gate. |

## Deciding whether to gate at all

The rest of the request describes a gate that is already being raised. `if` and `minRisk` decide
whether it is raised at all, and they are **data on the request**, not code around it.

That is the difference between one bit per tool and a policy. A `refund` tool that always gates wakes
someone for a $2 refund; one that never gates does not wake anyone for a $50,000 one. A rule is the
third answer:

```ts theme={null}
const decision = await ctx.step.approval("refund-gate", {
  tool: "issue-refund",
  args: { orderId, amount },
  risk: "high",
  minRisk: "medium",
  if: "args.amount > 1000.0",
});
```

**`if` and `minRisk` together are a conjunction.** The gate is raised only when the request's risk
clears the floor **and** the expression is true. Neither half overrides the other, and both are
evaluated in the same place, so a run never records half a verdict.

Inside an agent the same rule can be declared on the tool or on the agent as a default. Which of the
two applies to a given call is on the
[agent kit page](/agent-kit/approvals#which-rule-applies-to-a-tool); everything below holds either way.

### The four names

A rule is evaluated by Duraton, in [CEL](https://cel.dev) - the same dialect as a [trigger
`if`](/core/triggers), never a second language and never a closure in your runner. Its environment
is exactly four names:

| Name | Type | Is |
| - | - | - |
| `tool` | `string` | The action awaiting sign-off: the request's own `tool`. |
| `args` | map | The proposed arguments: the request's own `args`. An empty map when the call proposed none. |
| `risk` | `string` | The risk the approval will be stored at - the request's `risk`, or `"medium"` when it states none. |
| `iteration` | `int` | The agent loop's turn this call was made on. `0` on a gate with no loop. |

**The set is closed.** A rule naming a fifth name does not quietly evaluate to false - it does not
compile, and a rule that does not compile **fails the step**, in the run you are looking at. That is
deliberate. A `waitForEvent` predicate with a typo silently never matches and you find out by waiting
out a timeout; a gate is not something to learn about that way.

`iteration` is bound on every path, including the ones with no loop, where it is `0`. So
`iteration > 3` outside an agent is false rather than an error - and since an erroring rule
[raises the gate](#a-rule-that-cannot-be-evaluated), an unbound name would have gated every call.

<Warning>
  **Every JSON number reaches CEL as a double**, so `args.amount > 1000` is a double comparison. The
  payload is parsed without a target type, so an expression that depends on integer semantics does not
  behave as written. The same trap applies to a trigger `if`, for the same reason.
</Warning>

CEL raises an error on a missing key rather than answering false, so guard a field the model may not
have produced. An unguarded `args.amount` on a call that sent none errors, and an erroring rule
gates: safe, but it wakes a reviewer on every such call.

```ts theme={null}
// Errors, and therefore gates, on any call whose args carry no amount.
const strict = { tool: "issue-refund", args, if: "args.amount > 1000.0" };

// Answers false on that call, which is what you meant.
const guarded = { tool: "issue-refund", args, if: "has(args.amount) && args.amount > 1000.0" };
```

### When a rule declines the gate

No approval is created, nothing lands in anyone's inbox, and the tool runs. The step is still
written, and it completes with the same decision shape a real approval resolves to:

| Property | Value |
| - | - |
| `status` | `"approved"` |
| `decision` | `"approve"` |
| `args` | the proposed args, unchanged |
| `decidedBy` | `"system:rule"` |

So the run's own record shows the gate was evaluated and not raised, and `system:rule` reads beside
[`system:timeout`](#timeouts-and-escalation): an auditor asking who let a tool run finds a named
system actor rather than the blank that would read as an unattributed human.

**A declined gate still counts as a durable step.** The rule evaluation is a durable step
like any other, so it is billed the same way regardless of whether it raised the gate.

### A rule that cannot be evaluated

**An erroring rule raises the gate.** A missing key, a type mismatch, a comparison CEL will not
make: each of them parks the run on a person rather than letting the call through.

This is the opposite of what a [`waitForEvent`](/core/steps) predicate does, and
deliberately so. A waiter whose predicate breaks is treated as *no match*: it stays parked and
matures at its own timeout, which is the conservative reading there. Here *no match* means run the
tool with nobody watching. A rule nobody can evaluate is not evidence that the call is safe.

A rule that will not **compile** is a different failure: that is a bad request rather than a bad
evaluation, so the step fails outright instead of gating.

### When a rule beats a hand-written `if`

You can always write the condition yourself, and for a workflow a person maintains in an editor it is
often the clearest thing to write:

```ts theme={null}
if (amount > 1000) {
  const decision = await ctx.step.approval("refund-gate", { tool: "issue-refund", args });
  if (decision.status === "denied") return { outcome: "denied" };
}
```

It is also the whole of what it can be. A condition in your own language is a closure: it lives in
the runner that holds it, and nothing outside that runner can read it.

| | A hand-written `if` | A rule |
| - | - | - |
| Where the condition lives | your handler's source | the request, as data |
| Who can author it | someone editing that file | anyone who can fill in a string |
| Readable outside the runner | no | yes |
| Stored, versioned and diffed as data | no | yes |
| Who evaluates it | your runner, in your language | Duraton, in one CEL dialect |
| What the run records when it does not gate | whatever your code did next | the gate, evaluated and declined |

The last two rows are the ones that decide it. A hand-written `if` that skips the gate leaves nothing
behind saying a gate existed at all, while a rule that declines writes its verdict onto the step. And
because Duraton evaluates the expression rather than your runner, `args.amount > 1000.0` means one
thing rather than one thing per runner.

Reach for a rule when the condition is **policy** - something an operator states, a form edits, or an
auditor reads. Reach for a hand-written `if` when the condition is ordinary program logic that
happens to sit in front of a gate.

### Behaviour hints never gate

`destructiveHint` and its siblings on a tool's
[`annotations`](/agent-kit/agents-and-tools#behaviour-hints) describe how a tool behaves. They may inform a
default an author then owns, and they do nothing else: `requiresApproval` and its rule are the
enforcement boundary, and a hint is never a fifth name in the environment above.

MCP states the same rule for the same field names - a client is told never to make tool-use decisions
on annotations received from a server it does not trust - and the reason bites hardest on an
[attached MCP server](/agent-kit/providers-and-mcp#attaching-an-external-mcp-server), where the hint is written by
that server's operator rather than by you. `destructiveHint: true` on its own stops nothing. Set a
gate on a tool that is genuinely destructive.

## The four decisions

A reviewer does one of four things. They are the same four every agent framework converged on, so a
tool gated here behaves the way an author coming from elsewhere expects.

| Decision | What runs | What the agent gets back | `status` |
| - | - | - | - |
| `approve` | the tool, with the proposed args | the tool's real output | `approved` |
| `edit` | the tool, with the reviewer's args | the tool's real output | `approved` |
| `reject` | nothing | a denial carrying `reason` | `denied` |
| `respond` | nothing | `response`, **as the tool result** | `denied` |

`reject` and `respond` are not interchangeable. `reject` says *do not do this, here is why*;
`respond` says *do not do this, here is the answer instead* - the human did the tool's job, so their
answer is what the tool call returns.

<Note>
  Give a reason when you reject. Without one the agent's only honest next move is to try the same
  call again; with one it can pick a different action, ask a clarifying question, or stop. An
  [`onTimeout: "reject"`](#timeouts-and-escalation) writes its own reason, so a refusal the clock made
  is never a blank one.
</Note>

## The result

The step resolves to the decision once it is made:

| Property | Type | Description |
| - | - | - |
| `decision` | `HitlDecision` | What the decider did: `approve`, `edit`, `reject` or `respond`. |
| `status` | `"approved"` \| `"denied"` | Where that left the approval. A denial is not an error - the workflow branches on it. |
| `args` | `A` | The **effective** arguments: the decider's edits when they changed them, otherwise the proposed `args`. |
| `decidedBy` | `string` | Who decided, as recorded by the engine from the authenticated caller - not settable by the request. A [timeout resolution](#timeouts-and-escalation) records `system:timeout`. |
| `reason` | `string` | Why the call was refused, on `reject`. |
| `response` | `unknown` | The decider's answer standing in for the tool's output, on `respond`. |

## Deciding

An open approval shows up in the **Approvals** inbox in the console: the proposed tool call, its
risk, the run it belongs to, and an editable view of the arguments. Each of the four decisions
resumes the parked run, and each is recorded in the control-action audit log.

The console offers only the decisions the request allows. **Approve** becomes **Approve with edits**
once you change the arguments, so the verb follows what you actually did rather than needing a
second button. **Reject** and **Respond** each ask you to write the refusal first - a rejection with
nothing in it leaves the agent to retry the identical call - and a response is required, since it
stands in for the tool's output. With `allow: ["approve", "reject"]` the arguments stay locked and
no respond affordance appears at all.

A decided approval records **how** it was decided as well as by whom - `decidedVia` is `console`,
`mcp`, `api`, `timeout`, or `unrecorded`. It is always written by the engine and never settable by
the caller: from how the request authenticated, or from the clock when there was no request. The console shows it on the decision ("approved by [alice@example.com](mailto:alice@example.com) via
console"), because a person clearing a gate from a signed-in session and an agent clearing it with a
write tool are not the same evidence. `timeout` is the case with no caller at all: the approval's own
[`onTimeout`](#timeouts-and-escalation) resolved it, which reads as "approved by system:timeout via
timeout" and is deliberately not mistakable for a person. See the
[approval object reference](/reference/api/approvals#deciding).

Every approvals action in the console is also an MCP tool, so an AI agent can work the same inbox -
a triage agent that clears routine requests and escalates the rest is a supported use, not a
workaround:

| Action | REST | MCP tool |
| - | - | - |
| List open approvals | `GET /approvals?status=pending` | `list_approvals` |
| Inspect one | `GET /approvals/:id` | `get_approval` |
| Approve (optionally editing args) | `POST /approvals/:id/decision` | `approve_approval` |
| Reject, with a reason | `POST /approvals/:id/decision` | `deny_approval` |

The request and decision payloads are in the [approvals API reference](/reference/api/approvals); the tool
list is in the [MCP reference](/integrations/mcp-server).

### Who may decide what

A gate exists to put a named someone between a proposed action and its execution. You can require
that above a chosen risk level, that someone is a **person**: an approval at or above the floor is
refused for any caller authenticating as a credential rather than as a signed-in human, and answers
`403`.

The floor is a platform setting, one of `low`, `medium` or `high`. With a floor of `high`:

| Risk | An agent or an API key | A signed-in person |
| - | - | - |
| `low` | decides | decides |
| `medium` | decides | decides |
| `high` | **refused** | decides |

<Warning>
  The floor reads the risk **stored on the approval**, and a gate that states no risk is stored at
  `medium`. A tool gated inside an agent loop states one through
  [`approval.risk`](/agent-kit/approvals#tools-a-human-has-to-approve); one that does not can never be at or
  above a floor of `high`, however risky the call actually is.
</Warning>

**There is no floor unless you set one**, and that default is deliberate. The floor is only useful
where a human has a decision surface to use it from: the Duraton console signs decisions as the
person who made them, so it sets `high`. A bare engine with no signed decision surface would make a
high-risk gate undecidable by anyone. Set one once your operators have a way to sign a decision as
themselves.

The check runs when the decision is applied, so it holds identically over the API, over MCP, and over
any surface added later - narrowing one of them would leave the rest open. Reading is never
restricted: an agent can always list and inspect the inbox and tell its operator what is waiting.

The floor bounds the clock as well as callers: a gate at or above it cannot set
[`onTimeout: "approve"`](#what-a-deadline-may-not-decide), so a deadline never clears what an API key
would have been refused.

Set `risk` on the gate to place it (see [The request](#the-request) above). An approval with no risk
is never above the floor - classify a call before relying on a gate to hold it.

## Timeouts and escalation

`timeout` sets the deadline; `onTimeout` says what reaching it does. **There is no default timeout**:
an approval that sets none waits indefinitely, and resumes on a real decision however late it
arrives.

| `onTimeout` | At the deadline | The approval ends at | The run |
| - | - | - | - |
| `escalate` **(default)** | flips `pending` to `escalated` and notifies `escalatesTo` | stays open | stays suspended, still waiting for a decision |
| `approve` | resolves the gate as an approval of the proposed args | `approved` | resumes with `decision: "approve"`, so the tool call goes ahead |
| `reject` | refuses the call, with `no decision before <deadline>` as the `reason` | `denied` | resumes; the agent reads the refusal as the tool's result, exactly like a human rejection |
| `fail` | fails the parked step | `cancelled` - terminal, with no decision on record | fails with the step |

Omitting `onTimeout` is `escalate`, which is what a deadline has always meant here: overdue is not
decided. Opt in to the other three per gate, where "nobody looked" has a right answer.

```ts theme={null}
// A digest that has to go out on time: if nobody looked, send what was drafted.
await ctx.step.approval("digest-gate", {
  tool: "send-digest",
  args: { audience: "subscribers" },
  risk: "low",
  timeout: "2h",
  onTimeout: "approve",
});

// A payout the business must never make unattended: no decision is a failure.
const payout = await ctx.step.approval("payout-gate", {
  tool: "release-payout",
  args: { vendorId, amount },
  risk: "high",
  escalatesTo: "#finance",
  timeout: "24h",
  onTimeout: "fail",
});
if (payout.status === "denied") return { outcome: "held", reason: payout.reason };
```

Every resolution the clock made is recorded as `decidedBy: "system:timeout"` and
`decidedVia: "timeout"` - see [Deciding](#deciding). Nobody decided, and the record says so rather
than leaving an unattributed decision behind.

### What a deadline may not decide

Three configurations are refused, all of them cases where the clock would do something no reviewer
was offered:

| Configuration | Why |
| - | - |
| `onTimeout: "approve"` with `risk: "high"` | An unattended auto-approval is the exact failure a high-risk gate exists to prevent. |
| `onTimeout: "approve"` at or above the human-decision floor | A deadline is not a human either, so it must not clear what [an API key would be refused](#who-may-decide-what). |
| `onTimeout` naming a verb `allow` leaves out, e.g. `allow: ["reject"]` with `onTimeout: "approve"` | The deadline may only do what the request offered a reviewer. |

The engine checks all three **when the approval is created**, not when the deadline arrives: a
refused request fails the step immediately, so the author sees it in the run they are looking at
rather than hours later in a run nobody is watching. `escalate` and `fail` decide nothing, so `allow`
does not constrain them.

## Driving decisions from code

The same endpoints back the client, so a test - or a bot that auto-approves low-risk calls - can drive
an approval end to end:

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

const duraton = createClient({
  url: process.env.DURATON_URL!,
  apiKey: process.env.DURATON_API_KEY,
});

const [open] = await duraton.approvals.list({ status: "pending" });

if (open.risk === "high") {
  await duraton.approvals.decide(open.id, {
    decision: "reject",
    reason: "over the auto-approval limit - a person has to look at this one",
  });
} else {
  // Edit: halve the refund, and the parked run resumes with the new args.
  await duraton.approvals.decide(open.id, {
    decision: "edit",
    args: { orderId: "A1", amount: 2100, currency: "usd", reason: "billing_error" },
  });
}
```

The edited run resumes at `refund-gate` and receives the new arguments as `decision.args`; the
rejected run resumes with the reason as its tool result, so the agent can act on it. Both decisions
stay listable afterwards (`duraton.approvals.list({ runId })`) as the audit trail.

<Note>
  `{ status: "approved" }` and `{ status: "denied" }` still decide an approval, and resolve to the
  same verb the engine would derive - `approved` with edited args is an `edit`, without them an
  `approve`, and `denied` is a `reject`. Naming the verb is clearer, and it is the only way to
  `respond`.
</Note>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.