Runs API

Find any run and see what it did: list, filter, paginate, and summarize runs, and read one run's steps, logs, and AI spend.

The read-only runs API backs the console's Runs views. Mutating actions (cancel, pause, resume, replay) live in the control API.

A run's detail in the console
Method + pathReturns
GET /runsA page of run summaries, newest first, plus an X-Next-Cursor header.
GET /runs/{id}One run, including its input and result.
GET /runs/{id}/stepsThe run's steps, in ticket.
GET /runs/{id}/logsThe run's captured ctx.log lines, in ticket.
GET /runs/{id}/streamA live SSE stream of the run's timeline (status + logs).
GET /runs/streamA live SSE stream of run status transitions across the project.
GET /runs/statsAggregate counts plus p50/p95 latency for the current filter.
GET /runs/timeseriesRun counts bucketed over time, with per-bucket duration averages and percentiles.
GET /ai/spendAI token/cost rollup - totals plus by-hour, by-model, by-workflow.
GET /sessionsConversation sessions: runs grouped by session id, with per-session rollups.
POST /runs/{id}/explainStream a redacted, plain-language explanation of a failed run (SSE).

Listing & filtering

GET /runs accepts these query parameters:

ParamMeaningDefault
appRestrict to one app.all apps
workflowRestrict to one workflow name.all workflows
statusOne run status: queued, running, waiting, paused, needs_attention, succeeded, failed, cancelled - each matches exactly (running is a run actively executing a step; waiting is a run parked on a retry backoff, sleep, event, or child). Two aliases group them: active selects the in-flight set (queued + running + waiting), and all means no status filter, identical to omitting the param.all
runTypeOne trigger kind: event, cron, eval, or manual.all
eventIdRestrict to the runs one event fanned out (event->run lineage).all
replayOfRestrict to the runs forked from one source run by replay / retry-from-step (replay->run lineage).all
sessionRestrict to one conversation session id (the session sent on the event).all
scoreNameRestrict to runs carrying a score of this name.all
minScore / maxScoreBound that score's value (inclusive). Ignored without scoreName.unbounded
tag.<key>Restrict to runs carrying that tag with the given value, e.g. tag.customerId=abc. Repeatable across keys; multiple tag filters are ANDed. Index-backed.all
qSubstring match on workflow name, run id, or app.none
deep1 to widen q to also match run input, result, and error content (a case-insensitive substring scan); ignored without q.off
sinceRFC3339 timestamp; only runs started at or after it.no lower bound
sortstarted, workflow, status, app, or duration.started
dirasc or desc.desc (newest first)
limitPage size, 1-200.30
cursorOpaque keyset cursor from a previous page (see below).none

deep=1 widens the scan from indexed run metadata to the run's stored JSON, so bound it: the payload scan runs inside whatever other filters you apply (since, status, app), and on a busy project a narrow time window is the difference between an index lookup and a full scan.

import { createClient } from "@duraton/sdk/client";

const duraton = createClient({ url: process.env.DURATON_URL! });
const page = await duraton.runs.list({ app: "demo", status: "failed", sort: "duration", dir: "desc", limit: 20 });
page.runs;        // Run[]
page.nextCursor;  // pass back as { cursor } for the next page

Each run carries triggerKind - one of event, cron, eval, or manual (see trigger kinds) - so a scheduled or manually-started run is distinguishable both in the list and on the run object.

An event-triggered run also carries eventName and eventId - the event that fanned it out (event->run lineage). eventId matches the event log's id, so GET /runs?eventId=<id> returns every run one event produced, and a run links back to its exact event. Cron, child (step.runWorkflow), and on-failure runs have no eventId.

A run created by replay or retry-from-step carries replayOf - the id of the source run it was forked from (replay->run lineage). GET /runs?replayOf=<id> returns every run forked from one source, and the new run links back to its origin. Original runs have no replayOf.

Run tags

Tags are customer-defined key/value metadata you attach to a run so you can slice runs by your own dimensions - customerId, region, plan, batchId, whatever you name. Every run carries its tags as a tags object on the run, and GET /runs?tag.<key>=<value> filters by them, index-backed (no full scan). The same filter applies to GET /runs/stats.

Set tags in two places:

  • On an event (events.send / POST /events): the event's tags attach to every run it triggers - natural for "tag all runs from this webhook / customer".
  • On a child run (step.runWorkflow): the tags apply to that child run.

Inheritance. A step.runWorkflow child inherits its parent run's tags and merges its own on top, with the child's value winning on a shared key. So a tag.customerId=X set on the top-level event also matches the child runs that run spawned - "show all work for customer X" spans the whole tree.

Limits. At most 20 tags per run; each key up to 64 characters matching [A-Za-z0-9_.-]; each value up to 256 characters. An over-limit or malformed tag is rejected with a 400 (it is never silently truncated).

import { createClient } from "@duraton/sdk/client";

const duraton = createClient({ url: process.env.DURATON_URL! });

// Tag every run this event triggers.
await duraton.events.send({
  name: "ticket.created",
  app: "support-app",
  data: { ticketId: "T-421" },
  tags: { customerId: "cus_42", region: "eu" },
});

// Filter runs by tag (AND across keys).
const page = await duraton.runs.list({ tags: { customerId: "cus_42", region: "eu" } });

Inside a workflow, tag a child run - it also inherits the parent's tags:

await ctx.step.runWorkflow("enrich", {
  name: "ticket.enrich",
  data: { ticketId: "T-421" },
  tags: { stage: "enrich" }, // + inherited customerId/region from the parent
});

Sending tags on an event is supported in the TypeScript, Python, and Go SDKs. The tag.<key> runs filter is in the TypeScript and Go SDKs today (Go ListRunsOptions.Tags); the Python filter option is on the roadmap. Either way the wire contract (tags on an event, tag.<key> on the runs filter) is stable, so any language can use tags over REST.

Look up a single run

When you know a run by a business key rather than its Duraton run id - "the run for ticketId=123" - tag it at trigger time and look it up by that tag. This is just the list query with limit=1: the filters (tag.<key>, app, workflow, status, session) narrow the set, and sort/dir decide which run you get when several match. There is no separate lookup endpoint - GET /runs already does it - and no "ambiguous match" error: the newest match wins by default, and you pick a different one by changing sort/dir.

The TypeScript SDK wraps this as runs.find(opts), which returns the one matching run or null:

import { createClient } from "@duraton/sdk/client";

const duraton = createClient({ url: process.env.DURATON_URL! });

// The most recent run tagged ticketId=123 (or null if there is none).
const run = await duraton.runs.find({ tags: { ticketId: "123" } });

// Narrow further, and pick the oldest match instead of the newest.
const first = await duraton.runs.find({
  tags: { ticketId: "123" },
  app: "support-app",
  status: "failed",
  dir: "asc",
});

For AI agents: the same lookup is an MCP tool, find_run - call it with tags (plus optional app/workflow/status and sort/dir) to get one run back as { found, run }. It sits beside get_run (by id) and list_runs (the full page) in the engine MCP tool list, so an agent can discover and use it without knowing a run id.

Keyset pagination

Paging is cursor-based (keyset), not offset-based. Each response carries up to limit run summaries plus an X-Next-Cursor header when more rows exist; pass that value back as ?cursor= to fetch the next (older) page:

# first page - read the X-Next-Cursor response header
curl -sD - "$DURATON_URL/runs?limit=2" -o /dev/null | grep -i x-next-cursor
# next page
curl "$DURATON_URL/runs?limit=2&cursor=<value-from-header>"

The cursor encodes the current sort position, so paging stays correct while new runs arrive - no rows are skipped or repeated the way offset paging drifts under concurrent inserts. The header is absent on the last page. A cursor is tied to the sort/dir it was issued for; changing either invalidates it, so start a fresh page when the ordering changes.

Run steps

GET /runs/{id}/steps returns the run's steps, ordered by execution position and then attempt. It takes no limit - every recorded step row for the run is returned. This is the read model behind a progress view and the run-detail step list.

[
  {
    "name": "validate", "index": 0, "attempt": 1, "status": "succeeded",
    "input": { "ticketId": "T-421" }, "output": { "ok": true },
    "startedAt": "2026-06-22T10:00:00Z", "endedAt": "2026-06-22T10:00:00Z", "durationMs": 12
  },
  {
    "name": "triage", "index": 1, "attempt": 1, "status": "failed",
    "error": { "message": "gateway timeout" },
    "willRetry": true, "runAfter": "2026-06-22T10:00:05Z", "nextAttemptAt": "2026-06-22T10:00:05Z",
    "startedAt": "2026-06-22T10:00:00Z", "endedAt": "2026-06-22T10:00:00Z", "durationMs": 30000
  },
  {
    "name": "triage", "index": 1, "attempt": 2, "status": "succeeded",
    "output": { "refundId": "re_9" },
    "startedAt": "2026-06-22T10:00:05Z", "endedAt": "2026-06-22T10:00:05Z", "durationMs": 240
  },
  {
    "name": "notify", "index": 2, "attempt": 1, "status": "skipped",
    "output": "customer opted out"
  }
]
FieldMeaning
nameThe step id (the first argument to ctx.step.*). Stable across attempts and across passes.
indexThe step's 0-based position in the run's execution ticket. All attempts of one step share the same index; a reused step id (x, x:1, x:2) resolves each distinct name to its own index.
attemptThe 1-based attempt number this row records. A retried step produces one row per attempt (see below).
statusrunning, waiting, succeeded, failed, skipped, or cancelled. waiting is a step parked on a sleep, a waitForEvent, or a child run.
inputThe step's recorded input. Present for a step.run(id, input, fn) that supplied one, and a synthesized descriptor for a structural step (a sleep records { sleepMs }, a waitForEvent records { event, timeoutMs }, a runWorkflow records { name, data, app, runner }, etc.). Absent when the step recorded none. An offloaded inference records only { model, offloaded: true } - never the prompt.
outputThe step's result on success. For a skipped step this holds the skip reason passed to ctx.step.skip (a string or JSON value); absent when the step was skipped with no reason. Absent while the step is running/waiting and on a failed step.
errorOn a failed step, the failure as { message, stack? }. Absent otherwise.
aiThe opaque step.ai.* journal block, present only on a step that made a model call. Duraton stores it verbatim and never parses it.
runAfterA future timestamp the step is scheduled to resume/retry at. On a failed step scheduled to retry it is the next-attempt time (mirrored as nextAttemptAt); on a waiting step it is the sleep wake time or waitForEvent timeout. Absent when neither applies.
willRetrytrue only on a failed step that has a retry scheduled. Absent (falsey) on a terminal failure - so a transient retry renders distinctly from a hard failure on reload, exactly as on the live stream.
nextAttemptAtThe next attempt's time, set together with willRetry. Absent on a terminal failure.
eventNameThe event a waitForEvent step is parked on. Absent on other step kinds.
startedAt / endedAtWhen the step (attempt) started and finished (RFC3339). endedAt is absent while it is still running or waiting.
durationMsThe attempt's wall-clock duration in whole milliseconds, once it has finished. A skipped step records 0.

Every attempt is returned, not just the latest. Each (step, attempt) is its own row, ordered by index then attempt. A step that failed and retried appears as a failed row (with willRetry / nextAttemptAt) followed by the row for the next attempt - the whole retry history is visible, so you never have to reconstruct it. To render one row per step, keep the highest-attempt row per index.

import { createClient } from "@duraton/sdk/client";

const duraton = createClient({ url: process.env.DURATON_URL! });
const steps = await duraton.runs.steps("<id>"); // Step[]

Run logs

GET /runs/{id}/logs returns the structured logs a run emitted via ctx.log, oldest first. Each entry is one captured line:

[
  { "seq": 1, "ts": "2026-06-22T10:00:00Z", "level": "info", "message": "ticket received", "fields": { "ticketId": "T-421" }, "scope": "@root", "attempt": 1 },
  { "seq": 2, "ts": "2026-06-22T10:00:00Z", "level": "info", "message": "triaging ticket", "fields": { "priority": "high" }, "scope": "triage", "attempt": 1 }
]
FieldMeaning
seqPer-run monotonic sequence; pass the last one you saw as ?from= to page forward.
tsWhen Duraton persisted the line (RFC3339).
leveldebug, info, warn, or error.
messageThe log message.
fieldsStructured fields, with sensitive keys redacted. Absent when none were logged.
scopeThe step the log came from, or @root for a handler-level log.
attemptThe attempt the line was recorded under.
ParamMeaningDefault
fromExclusive lower bound on seq; returns lines after it.0 (from the start)
limitPage size, 1-1000.100
curl "$DURATON_URL/runs/<id>/logs?from=0&limit=100"

Logs are captured once and persisted durably even under replay: a handler-level log re-runs on every pass but is recorded once, while a retried step's logs stay distinct per attempt. See the logging guide for how ctx.log works.

Live run stream

GET /runs/{id}/stream is a Server-Sent Events stream of a run's timeline: every status transition and ctx.log line, in ticket, as they happen. It replays the timeline from the start on connect, then tails new rows live, and ends on its own once the run is terminal. See the realtime guide for how it works.

Each event is one timeline frame. The SSE event: line carries the frame kind; the data: object repeats it so a non-browser client can discriminate without reading the line:

event: step_status
data: {"kind":"step_status","seq":7,"ts":"2026-06-22T10:00:01Z","runId":"01H...","name":"triage","status":"succeeded","attempt":1}

event: log
data: {"kind":"log","seq":8,"ts":"2026-06-22T10:00:01Z","runId":"01H...","level":"info","message":"triaged","scope":"triage","attempt":1}

event: run_status
data: {"kind":"run_status","seq":9,"ts":"2026-06-22T10:00:02Z","runId":"01H...","status":"succeeded"}

Every frame carries kind, seq, ts, and runId; the rest depends on kind:

kindExtra fields
run_statusstatus, currentStepName?, attempt?
step_statusname, status, attempt, index?, error?, willRetry?, nextAttemptAt?
loglevel, message, fields?, scope, attempt

On a step_status frame with status: "failed", willRetry is true and nextAttemptAt is the ISO time of the next attempt when the step is scheduled to retry; both are absent on a terminal failure. The same two fields appear on each step from GET /runs/{id}/steps, so a transient retry renders distinctly from a hard failure on reload as well as on the live stream.

ParamMeaningDefault
fromExclusive lower bound on seq; resumes a stream losslessly after a drop.0 (from the start)
import { createClient } from "@duraton/sdk/client";

const duraton = createClient({ url: process.env.DURATON_URL! });
for await (const frame of duraton.runs.watch("<id>")) {
  if (frame.kind === "log") console.log(frame.level, frame.message);
  else console.log(frame.kind, frame.status);
}

Frames are read from the run's stored timeline, not from an in-memory bus, and every frame carries a per-run monotonic seq. That is what makes a drop recoverable: remember the last seq you processed and reconnect with ?from=<seq>, and the stream replays every frame after it before tailing again.

Project-wide run stream

GET /runs/stream is the project-level counterpart: an SSE stream of run_status frames across every run in the project, for keeping a runs list or overview live without polling. It carries only run status transitions - not steps or logs - to stay bounded.

event: run_status
data: {"kind":"run_status","seq":12,"ts":"2026-06-22T10:00:03Z","runId":"01H...","status":"succeeded"}

Unlike the per-run stream there is no seq cursor here: a per-run seq is not ordered across runs, so this stream tails by timestamp and is best-effort. Treat each frame as a signal to refetch the affected run or the list, not as a lossless log - a missed frame is self-correcting, since the next transition triggers another refetch that also reflects the run you missed. The console's runs list and stats are built on exactly this: they refetch on activity instead of on a timer.

curl -N "$DURATON_URL/runs/stream"

Run stats

GET /runs/stats summarizes the run set for a filter. It accepts app, workflow, replayOf, and since (the same meaning as above):

curl "$DURATON_URL/runs/stats?app=demo&since=2026-06-01T00:00:00Z"
# { "total": 1575, "active": 1, "queued": 0, "running": 1, "succeeded": 1371, "failed": 202, "successRate": 87, "p50Ms": 740, "p95Ms": 3120 }
FieldMeaning
totalAll runs matching the filter.
activeNon-terminal runs: queued + running + waiting.
queuedRuns admitted and awaiting a worker. Always present, 0 when none.
runningRuns actively executing a step. Always present, 0 when none.
succeededRuns that finished successfully.
failedRuns that finished in failure.
successRatesucceeded / (succeeded + failed), rounded to a whole-number percent (100 when nothing has finished).
p50Ms / p95MsMedian and 95th-percentile run duration (whole milliseconds) over the filter's finished runs. Absent when none have finished. See percentile computation.

Run time series

GET /runs/timeseries buckets the same run set over time - the endpoint behind the console's run charts. It accepts app, workflow, and since, plus bucket (the bucket width in seconds, default 3600):

curl "$DURATON_URL/runs/timeseries?since=2026-07-01T00:00:00Z&bucket=3600"
{
  "bucketSeconds": 3600,
  "buckets": [
    { "ts": "2026-07-01T10:00:00Z", "counts": { "succeeded": 41, "failed": 2 }, "total": 43, "avgMs": 812, "maxMs": 4310, "p50Ms": 640, "p95Ms": 3980 }
  ]
}
FieldMeaning
bucketSecondsThe bucket width the series was built with.
buckets[].tsThe bucket's start (RFC3339, UTC).
buckets[].countsRuns in the bucket keyed by status; a status with no runs is absent.
buckets[].totalAll runs started in the bucket.
buckets[].avgMs / maxMsMean and longest run duration in the bucket, over its finished runs only. Absent when no run in it has finished.
buckets[].p50Ms / p95MsMedian and 95th-percentile run duration in the bucket, over its finished runs only. Absent when no run in it has finished. See percentile computation.

Runs are bucketed by when they started, oldest bucket first, and a status filter does not apply here

  • the point of the series is the status split within each bucket. A query is capped at 5000 (bucket, status) rows, keeping the most recent buckets; widen bucket to cover a longer window.

AI spend & sessions

Two read endpoints roll up Duraton's AI metering for observability. Both meter tokens and report cost only where a call supplied one - Duraton holds no price list, so cost fields stay absent rather than defaulting to 0.

GET /ai/spend returns window totals plus by-hour, by-model, and by-workflow breakdowns. It accepts app, workflow, and since (as above), plus bucket (the by-hour width in seconds, default 3600):

{
  "tokens": 2904, "calls": 9, "bucketSeconds": 3600,
  "hourly": [{ "ts": "2026-07-01T10:00:00Z", "tokens": 2904, "calls": 9 }],
  "byModel": [
    { "model": "demo-agent-1", "tokens": 2274, "calls": 3, "avgLatencyMs": 142 },
    { "model": "demo-generate-1", "tokens": 392, "calls": 2, "avgLatencyMs": 96 },
    { "model": "demo-chat-1", "tokens": 238, "calls": 1 },
    { "model": "demo-embed-1", "tokens": 0, "calls": 3 }
  ],
  "byWorkflow": [{ "workflow": "ai.triage", "app": "ai-demo", "tokens": 2904, "runs": 1 }],
  "avgLatencyMs": 118, "cacheHits": 0, "cacheEligible": 0
}

avgLatencyMs is the mean call latency across calls that reported one - absent when none did, never 0-filled - given for the window total and, in byModel, broken out per model (demo-chat-1 and demo-embed-1 above report no latency, so the field is simply absent on those rows). cacheHits over cacheEligible is the inference-cache hit rate, counting only calls that used the cache; both stay 0 when no call in the window engaged it.

GET /sessions groups runs into conversation sessions, most recent first. Set an event's session to a stable conversation id (the OpenTelemetry gen_ai.conversation.id) to thread its runs together; omit it and each run is its own session. It accepts app, since, and limit (default 100, max 500):

[
  {
    "session": "conv_18f", "runCount": 3,
    "statusCounts": { "succeeded": 3 }, "aiTokens": 2512,
    "firstStartedAt": "2026-07-01T10:00:00Z", "lastStartedAt": "2026-07-01T10:04:00Z"
  }
]

aiTokens and aiCost are absent when no run in the session made a model call / supplied a cost.

import { createClient } from "@duraton/sdk/client";

const duraton = createClient({ url: process.env.DURATON_URL! });
const spend = await duraton.ai.spend({ since: "2026-07-01T00:00:00Z" });
const sessions = await duraton.sessions.list({ app: "assistant" });

Explain a failure

POST /runs/{id}/explain streams a plain-language explanation of a failed run as Server-Sent Events. Duraton sends the model a redacted diagnosis - workflow, app, trigger, status, and per-step errors, but never inputs, outputs, prompts, or keys - and relays the answer token by token:

data: {"type":"token","text":"The "}
data: {"type":"token","text":"triage step timed out."}
data: {"type":"done"}

It responds 409 when the run is not failed, and 501 when explanations are not available for the project. See the observability guide.

for await (const token of duraton.runs.explain("<id>")) process.stdout.write(token);

On this page