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.
escalatesTostringnoneThe escalation target named on the approval once its timeout elapses.
timeoutstring | numbernever escalatesThe escalation deadline ("30m", or ms). It never decides the approval - see Timeouts.

The result

The step resolves to the decision once it is made:

PropertyTypeDescription
status"approved" | "denied"The decision. 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. 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:

ActionRESTMCP tool
List open approvalsGET /approvals?status=pendinglist_approvals
Inspect oneGET /approvals/:idget_approval
Approve (optionally editing args)POST /approvals/:id/decisionapprove_approval
DenyPOST /approvals/:id/decisiondeny_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.

On this page