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. |
escalatesTo | string | none | The escalation target named on the approval once its timeout elapses. |
timeout | string | number | never escalates | The escalation deadline ("30m", or ms). It never decides the approval - see Timeouts. |
The result
The step resolves to the decision once it is made:
| Property | Type | Description |
|---|---|---|
status | "approved" | "denied" | The decision. 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. Defaults to the API key that submitted the decision. |
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. Approve, approve after editing the args, or deny - each resumes the parked run. Decisions are recorded in the control-action audit log.
Every approvals action in the console is also an MCP tool, so an AI agent can work the same inbox - as it can every other console action, since an agent is a first-class user of Duraton:
| 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 |
| Deny | 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.
Timeouts and escalation
When timeout elapses, the approval's status changes from pending to escalated. It stays open
and the run stays suspended: escalated marks it as overdue and surfaces the escalatesTo target, and
nothing else changes. A timeout never approves and never denies - a run resumes only on a real
decision, however late it arrives.
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, { status: "denied", decidedBy: "billing-bot" });
} else {
// Approve with edits: halve the refund, and the parked run resumes with the new args.
await duraton.approvals.decide(open.id, {
status: "approved",
decidedBy: "billing-bot",
args: { orderId: "A1", amount: 2100, currency: "usd", reason: "billing_error" },
});
}The approved run resumes at refund-gate and receives the edited arguments as decision.args; the
denied run resumes and takes its denied branch. Both decisions stay listable afterwards
(duraton.approvals.list({ runId })) as the audit trail.
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.
Cost controls
Stop an agent before it overspends: cap halts before the call, budget pauses a rolling window, the cache replays identical calls free, fallback survives an outage.