Workflows API
See what your runners actually registered - triggers, schedules, retry policy, flow control, and the step manifest - and start a run by hand.
GET /workflows returns the workflow definitions currently registered in the project - the shape a
runner declared when it connected or served. It is the read model behind the console's
Workflows view. Definitions are registered by your runners; there is no write endpoint for them.
POST /workflows/{app}/{name}/trigger starts one off-schedule run of a registered workflow by identity -
see trigger a run manually below.
| Method + path | Returns |
|---|---|
GET /workflows | Every workflow registered in the project. |
POST /workflows/{app}/{name}/trigger | Starts one run of that workflow. 202 Accepted. |
GET /workflows takes no query parameters - it returns the whole set. Filter client-side by app when
you only want one app's workflows.
Response
An array of workflow definitions:
[
{
"name": "fulfillment",
"app": "support-app",
"maxAttempts": 3,
"backoff": "exponential",
"triggers": [
{ "event": "ticket.created" },
{ "event": "ticket.updated", "if": "event.data.priority == 'high'" }
],
"scheduled": false,
"flowControl": {
"concurrency": { "limit": 5, "key": "customerId" },
"idempotency": { "key": "ticketId", "periodMs": 86400000 }
},
"steps": [
{ "name": "validate", "description": "Validate the ticket" },
{ "name": "triage" },
{ "name": "audit", "hidden": true }
],
"registeredAt": "2026-06-01T09:00:00Z",
"updatedAt": "2026-06-22T10:00:00Z"
},
{
"name": "nightly-report",
"app": "support-app",
"maxAttempts": 1,
"backoff": "fixed",
"scheduled": true,
"schedules": [
{
"cron": "0 2 * * *",
"nextFireAt": "2026-06-23T02:00:00Z",
"lastFiredAt": "2026-06-22T02:00:00Z",
"isStale": false
}
],
"registeredAt": "2026-06-01T09:00:00Z",
"updatedAt": "2026-06-01T09:00:00Z"
}
]| Field | Meaning |
|---|---|
name | The registered workflow name - the dispatch key a run records and a runWorkflow targets. |
app | The app the workflow belongs to. |
maxAttempts | The workflow-wide retry attempt budget (1 = no retry). |
backoff | The retry backoff shape: fixed, linear, or exponential. The finer bounds (initialDelayMs / maxDelayMs) are applied at runtime and are not re-emitted here. |
triggers | The workflow's triggers. Each entry sets exactly one of event (with an optional if CEL guard) or cron. Absent when the workflow declared none (it is then implicitly triggered by an event matching its name). |
scheduled | true when the workflow has at least one cron schedule. Always present. |
schedules | The resolved cron schedules, each { cron, nextFireAt, lastFiredAt?, isStale } - see detecting a dead schedule. Absent when the workflow has no cron trigger. |
flowControl | The configured flow-control policies, in the same millisecond-based shape they were registered with. A present sub-field is an active policy; the whole object is absent when none are set (see below). |
steps | The advisory step manifest in declaration ticket. Absent when the workflow declared none. |
registeredAt | When the workflow was first registered (RFC3339). |
updatedAt | When its definition was last updated by a re-registration (RFC3339). |
Flow control
flowControl re-emits whatever flow-control policies the workflow registered, each
as an optional sub-field. A field is present only when that policy is active; all durations are
milliseconds:
| Sub-field | Shape |
|---|---|
concurrency | { limit, key? } |
throttle | { limit, perMs, key? } |
rateLimit | { limit, perMs, key? } |
debounce | { periodMs, key? } |
batch | { maxSize, timeoutMs, key? } |
priority | { shiftMs } |
singleton | { key?, mode? } |
idempotency | { key?, periodMs? } |
cap | { maxCost?, maxTokens? } - a per-run AI spend ceiling |
budget | { maxCost?, maxTokens?, windowMs, warnAtPct? } - a rolling-window AI spend ceiling |
tokenThrottle | { tokens, perMs, key? } - a token-denominated AI throttle |
Step manifest
steps is the workflow's advisory manifest of declared steps, in declaration ticket. Each entry is:
| Field | Meaning |
|---|---|
name | The declared step id - the id the run view diffs against the run's actually-executed steps. |
description | An optional human label for the step. Absent when none was declared. |
hidden | true to exclude a bookkeeping step from a customer-facing progress view. Absent (falsey) otherwise. |
The manifest is rendering metadata only: it never gates execution, never fails a run for drift, and never matches emitted opcodes. When a run's executed steps disagree with the manifest, discovery wins - the run view shows what actually ran. A workflow that declares no manifest behaves exactly the same; the field is simply absent.
import { createClient } from "@duraton/sdk/client";
const duraton = createClient({ url: process.env.DURATON_URL! });
const workflows = await duraton.workflows.list(); // WorkflowDef[]import (
"context"
"duraton.dev/sdk-go/client"
)
dx := client.New(client.Options{})
workflows, err := dx.Workflows(context.Background()) // []client.WorkflowDefcurl "$DURATON_URL/workflows"The typed workflows read is currently available in the TypeScript and Go SDKs; the Python read
helper is on the roadmap. The wire contract (GET /workflows) is stable, so any language can read it
over REST today.
Trigger a run manually
POST /workflows/{app}/{name}/trigger starts one run of one workflow by identity, independent of its
declared triggers. It works even for a workflow with only a cron trigger, which POST /events cannot
reach - there is no event to send it. See the manual trigger guide
for the cron-only case, how this differs from POST /events, and why flow control still applies.
Every field is optional, and a request with no body at all is valid - that's the cron-only case:
import { createClient } from "@duraton/sdk/client";
const duraton = createClient({ url: process.env.DURATON_URL! });
// Just fire it.
const res = await duraton.workflows.trigger("support-app", "nightly-report");
res.runId; // absent if a flow-control gate short-circuited it
// With custom input, an event-name label, and tags.
await duraton.workflows.trigger("support-app", "fulfillment", {
input: { ticketId: "T-421" },
eventName: "ticket.created",
tags: { team: "support" },
});from duraton.client import AsyncDuratonClient, TriggerWorkflowInput
async with AsyncDuratonClient() as dx:
# Just fire it.
res = await dx.workflows.trigger("support-app", "nightly-report")
res.run_id # None if a flow-control gate short-circuited it
# With custom input, an event-name label, and tags.
await dx.workflows.trigger(
"support-app",
"fulfillment",
TriggerWorkflowInput(input={"ticketId": "T-421"}, event_name="ticket.created", tags={"team": "support"}),
)import (
"context"
"encoding/json"
"duraton.dev/sdk-go/client"
)
dx := client.New(client.Options{})
// Just fire it.
res, err := dx.TriggerWorkflow(context.Background(), "support-app", "nightly-report", client.TriggerWorkflowInput{})
res.RunID // empty if a flow-control gate short-circuited it
// With custom input, an event-name label, and tags.
_, err = dx.TriggerWorkflow(context.Background(), "support-app", "fulfillment", client.TriggerWorkflowInput{
Input: json.RawMessage(`{"ticketId":"T-421"}`),
EventName: "ticket.created",
Tags: map[string]string{"team": "support"},
})# Just fire it - no body needed.
curl -X POST "$DURATON_URL/workflows/support-app/nightly-report/trigger" \
-H "Authorization: Bearer $DURATON_API_KEY"
# With custom input, an event-name label, and tags.
curl -X POST "$DURATON_URL/workflows/support-app/fulfillment/trigger" \
-H "Authorization: Bearer $DURATON_API_KEY" \
-d '{"input":{"ticketId":"T-421"},"eventName":"ticket.created","tags":{"team":"support"}}'Request body
| Field | Type | Meaning |
|---|---|---|
input | any JSON | Becomes ctx.event.data on the run. Defaults to {} (never null) when omitted. |
eventName | string | Overrides ctx.event.name. Defaults to the workflow's own name. Label only - it never fires an event, causes fan-out, or wakes a waitForEvent step. |
dedupeId | string | Drop a repeat of the same id (per workflow) within the dedupe window. Absent means no dedupe: a second call is a second run. |
runner | string | Pin the run to a specific runner id. Anycast (any capable runner) when absent. |
tags | object of string->string | Customer-defined key/value metadata attached to the run - same limits as event tags. |
eventName, dedupeId, and runner are each bounded at 256 characters; a longer value or unparseable
input returns 400.
Response
202 Accepted. The run is created and enqueued, not executed - a caller that wants the finished result
polls GET /runs/{id} or uses runs.wait.
{
"workflow": "nightly-report",
"app": "support-app",
"runId": "01HXYZ...",
"eventName": "nightly-report"
}| Field | Meaning |
|---|---|
workflow / app | The workflow the request addressed. |
runId | The run started. Absent when a flow-control gate short-circuited the request - not an error. |
eventName | The resolved ctx.event.name on the run: your eventName override, or the workflow's own name. |
skipped / dropped / debounced / batched / deduped | Flow-control outcomes - set (and runId absent) when the request was held back rather than run immediately. At most one is set. See flow control. |
A workflow not registered in the project (or registered in another project) returns 404. No live
runner capable of serving the workflow returns 502 - the run was never queued, so there's nothing to
poll.