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.
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));from duraton import ApprovalRequest
decision = await ctx.step.approval("refund-gate", ApprovalRequest(
tool="issue-refund",
args={"orderId": order_id, "amount": amount, "currency": "usd", "reason": "billing_error"},
risk="high",
summary=f"Refund {amount} to {order_id} for a duplicate charge",
policy="tools.issue-refund -> require approval",
escalates_to="#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.
refund = await ctx.step.run("issue-refund", lambda: issue(decision.args))decision, err := duraton.Approval[RefundArgs](c, "refund-gate", duraton.ApprovalRequest{
Tool: "issue-refund",
Args: RefundArgs{OrderID: orderID, Amount: amount, Currency: "usd", Reason: "billing_error"},
Risk: duraton.RiskHigh,
Summary: fmt.Sprintf("Refund %d to %s for a duplicate charge", amount, orderID),
Policy: "tools.issue-refund -> require approval",
EscalatesTo: "#support-leads",
Timeout: 30 * time.Minute,
})
if err != nil {
return nil, err
}
if decision.Status == duraton.Denied {
return map[string]any{"outcome": "denied"}, nil
}
// decision.Args are the effective args - the decider's edits when changed, else the proposed ones.
refund, err := duraton.Run(c, "issue-refund", func() (any, error) { return 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.
| 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. 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. |
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:
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:
| 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, 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:
| 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: 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 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 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.
| 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.
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:
| 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 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 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:
| 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; 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:
| Risk | An agent or an API key | A signed-in person |
|---|---|---|
low | decides | decides |
medium | decides | decides |
high | refused | decides |
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.
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.
// 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:
| 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 DURATON_APPROVAL_HUMAN_RISK_FLOOR | A 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.
Streaming
Show a viewer tokens as the model produces them and still get one durable result - the stream replays from token 0, the memoized value is the full text.
Guardrails
Check a model's tool arguments before the tool runs. The verdict is a durable step, so a replay reads what was decided instead of deciding again.