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.

An approval awaiting a human decision in the console
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 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.

PropertyTypeDefaultDescription
toolstringrequiredThe action awaiting sign-off, e.g. "issue-refund".
argsAnoneThe proposed input for that action. The decider sees it and may edit it before approving.
risk"low" | "medium" | "high"noneThe declared risk level, shown on the approval in the inbox.
summarystringnoneA one-line human description of what is being asked.
policystringnoneThe rule that required an approval here, recorded on the request.
contextstringnoneAny further background for the reviewer.
allowHitlDecision[]every decisionWhich decisions this reviewer may take. ["approve", "reject"] offers no edit affordance, and the engine refuses a verb the request did not offer.
escalatesTostringnoneThe escalation target named on the approval once its timeout elapses.
timeoutstring | numberno deadlineThe 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. Ignored without a timeout: there is no deadline to reach.
ifstringalways gateA CEL condition deciding whether the gate is raised at all - see Deciding whether to gate at all.
minRisk"low" | "medium" | "high"no floorA floor on this request's own risk: gate only at or above it.
iterationnumber0The 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:

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; everything below holds either way.

The four names

A rule is evaluated by Duraton, in CEL - the same dialect as a trigger if and a Kafka filter, never a second language and never a closure in your runner. Its environment is exactly four names:

NameTypeIs
toolstringThe action awaiting sign-off: the request's own tool.
argsmapThe proposed arguments: the request's own args. An empty map when the call proposed none.
riskstringThe risk the approval will be stored at - the request's risk, or "medium" when it states none.
iterationintThe 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, an unbound name would have gated every call.

Every JSON number reaches CEL as a double, so args.amount > 1000 is a double comparison. It is the same dialect trap the Kafka filter page describes, for the same reason: the payload is parsed without a target type. An expression that depends on integer semantics does not behave as written.

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.

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

PropertyValue
status"approved"
decision"approve"
argsthe 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: 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 workflow_step row 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 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:

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 ifA rule
Where the condition livesyour handler's sourcethe request, as data
Who can author itsomeone editing that fileanyone who can fill in a string
Readable outside the runnernoyes
Stored, versioned and diffed as datanoyes
Who evaluates ityour runner, in your languageDuraton, in one CEL dialect
What the run records when it does not gatewhatever your code did nextthe 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 each SDK, args.amount > 1000.0 means one thing rather than one thing per language.

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 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, 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.

DecisionWhat runsWhat the agent gets backstatus
approvethe tool, with the proposed argsthe tool's real outputapproved
editthe tool, with the reviewer's argsthe tool's real outputapproved
rejectnothinga denial carrying reasondenied
respondnothingresponse, as the tool resultdenied

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.

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" writes its own reason, so a refusal the clock made is never a blank one.

The result

The step resolves to the decision once it is made:

PropertyTypeDescription
decisionHitlDecisionWhat 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.
argsAThe effective arguments: the decider's edits when they changed them, otherwise the proposed args.
decidedBystringWho decided, as recorded by the engine from the authenticated caller - not settable by the request. A timeout resolution records system:timeout.
reasonstringWhy the call was refused, on reject.
responseunknownThe 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 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 resolved it, which reads as "approved by system:timeout via timeout" and is deliberately not mistakable for a person. See the approval object reference.

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:

ActionRESTMCP tool
List open approvalsGET /approvals?status=pendinglist_approvals
Inspect oneGET /approvals/:idget_approval
Approve (optionally editing args)POST /approvals/:id/decisionapprove_approval
Reject, with a reasonPOST /approvals/:id/decisiondeny_approval

The request and decision payloads are in the approvals API reference; the tool list is in the MCP reference.

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.

Set the floor with DURATON_APPROVAL_HUMAN_RISK_FLOOR (low, medium or high). With DURATON_APPROVAL_HUMAN_RISK_FLOOR=high:

RiskAn agent or an API keyA signed-in person
lowdecidesdecides
mediumdecidesdecides
highrefuseddecides

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; one that does not can never be at or above a floor of high, however risky the call actually is.

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: Duraton Cloud's console signs decisions as the person who made them, so it sets high. A standalone engine's built-in dashboard is read-only, so a floor there 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", so a deadline never clears what an API key would have been refused.

Set risk on the gate to place it (see 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.

onTimeoutAt the deadlineThe approval ends atThe run
escalate (default)flips pending to escalated and notifies escalatesTostays openstays suspended, still waiting for a decision
approveresolves the gate as an approval of the proposed argsapprovedresumes with decision: "approve", so the tool call goes ahead
rejectrefuses the call, with no decision before <deadline> as the reasondeniedresumes; the agent reads the refusal as the tool's result, exactly like a human rejection
failfails the parked stepcancelled - terminal, with no decision on recordfails 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.

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

ConfigurationWhy
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 DURATON_APPROVAL_HUMAN_RISK_FLOORA deadline is not a human either, so it must not clear what an API key would be refused.
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:

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.

{ 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.

On this page