Approvals API
Decide the runs waiting on a person from your own tooling: list open approvals and approve, deny, or approve with edits over HTTP.
An approval is a step that parks its run in needs_attention until someone
approves or denies it; the decision resumes the run from its checkpoint. Reads work with a public
key; the decision needs a secret key.
Endpoints
| Method + path | Purpose |
|---|---|
GET /approvals | The project's approvals, newest first. |
GET /approvals/{id} | One approval, with the proposed tool call and its context. |
POST /approvals/{id}/decision | Approve or deny an open approval; the parked run resumes. |
Listing
GET /approvals accepts:
| Param | Meaning | Default |
|---|---|---|
status | One of pending, escalated, approved, denied, cancelled. pending and escalated are open, awaiting a decision. An unknown value returns 400. | all |
runId | Only one run's approvals. | all |
limit | Page size, 1-1000. A non-integer returns 400. | 100 |
A decided approval stays listable as its audit trail. Each approval is:
{
"id": "01JZR4A0...", "runId": "01JZR3Z9...",
"workflow": "support.refund", "app": "support", "step": "refund-gate",
"tool": "issue-refund",
"args": { "orderId": "A1", "amount": 4200, "currency": "usd" },
"risk": "high",
"policy": "tools.issue-refund -> require approval",
"summary": "Refund 4200 to A1 for a duplicate charge",
"escalatesTo": "#support-leads",
"onTimeout": "escalate",
"status": "pending",
"requestedAt": "2026-07-01T10:00:00Z",
"expiresAt": "2026-07-01T10:30:00Z"
}| Field | Meaning |
|---|---|
id, runId | The approval and the run parked on it. |
workflow, app | Joined from the parked run. |
step | The step.approval name in the workflow. |
tool, args | The proposed action awaiting sign-off. |
risk | The declared risk level. |
policy, summary, context | Annotations for whoever reviews it. Absent when not set. |
escalatesTo | The escalation target once expiresAt passes, on the default onTimeout: "escalate". |
onTimeout | What the deadline does to this approval undecided: escalate (the default), approve, reject, or fail. See Timeouts. |
status | pending, escalated, approved, denied, or cancelled. cancelled is terminal with no decision on record: the run went terminal under it, or its onTimeout was fail. |
requestedAt, expiresAt | When it was requested, and when its onTimeout action fires. expiresAt absent without a timeout. |
decidedAt, decidedBy, editedArgs | Set once decided: when, by whom, and the decider's edited arguments (approve-with-edits). Absent while open. |
decidedVia | Which surface the decision arrived through. See Deciding. |
Deciding
POST /approvals/{id}/decision applies the decision and resumes the run:
{ "status": "approved", "args": { "orderId": "A1", "amount": 2100, "currency": "usd" } }status must be approved or denied. args, when present, replaces the proposed tool arguments
on the approved decision - approve-with-edits; the workflow receives them as the effective
decision.args. The response is the decided approval.
The decider is recorded from the authenticated caller: the X-Duraton-Actor header when a
platform-issued key supplies one, otherwise the API key's name. Scope does not grant that - a
customer key is full-scope too, and one that could name a person would be signing their name to its
own actions. The request body cannot set it, so a decision can never be attributed to someone who
did not make it, and the approval record always agrees with the audit log.
decidedVia records which surface the decision arrived through, alongside who made it. It is
derived the same way - from how the request authenticated - and is likewise not settable by the
body, because the surfaces do not carry the same weight:
decidedVia | Recorded when | What it evidences |
|---|---|---|
console | A platform key naming the acting person | A signed-in person decided |
mcp | The request reached /mcp | An agent decided with a write tool |
api | Any other authenticated call | A credential decided; nobody is named |
timeout | The approval's own onTimeout resolved it | Nobody decided; the deadline did |
unrecorded | Decided before this field existed, or with auth disabled | Nothing is claimed |
An auditor asking how a high-risk tool call was cleared needs that difference: the same person
clearing a gate from a signed-in session and from a raw API call leaves the same decidedBy. A
timeout resolution has no caller to derive either field
from, so it records decidedBy: "system:timeout" alongside decidedVia: "timeout" rather than an
empty decider that would read as an unattributed person.
import { createClient } from "@duraton/sdk/client";
const duraton = createClient({ url: process.env.DURATON_URL! });
const open = await duraton.approvals.list({ status: "pending" });
await duraton.approvals.decide(open[0].id, { status: "approved" });from duraton.client import ApprovalDecisionInput, AsyncDuratonClient, ListApprovalsOptions
async with AsyncDuratonClient() as dx:
open_approvals = await dx.approvals.list(ListApprovalsOptions(status="pending"))
await dx.approvals.decide(open_approvals[0].id, ApprovalDecisionInput(status="approved"))curl "$DURATON_URL/approvals?status=pending"
curl -X POST "$DURATON_URL/approvals/01JZR4A0.../decision" -d '{"status":"approved"}'
curl -X POST "$DURATON_URL/approvals/01JZR4A0.../decision" -d '{"status":"denied"}'Error codes
| Status | When |
|---|---|
400 | An unknown status filter; a decision whose status is not approved/denied; or invalid args JSON. |
404 | The approval id does not exist. |
409 | Deciding an approval that is no longer open: already decided, already resolved by its onTimeout, or cancelled. |
Control API
Take control of a run in flight: cancel, pause, resume, replay it, or retry from a step - plain HTTP, with replay and retry forking a new run.
Evals API
Measure agent quality from your own tooling: score runs, author datasets of test cases, fan them through a workflow, and fork a finished run with one change.