Skip to main content
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

Listing & filtering

GET /runs accepts these query parameters: 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.
Each run carries triggerKind - one of event, cron, 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. A run spawned by a step.runWorkflow call - a workflow-backed agent tool included - carries the call edge it was spawned on (call->run lineage): parentRunId (the run that spawned it), parentStep (the step it was spawned on) and parentAttempt (which attempt of that step spawned it). The spawning step carries childRunId back, so the edge is followable both ways. GET /runs?parentRunId=<id> returns every child one run started - exact equality on one nesting level, so direct children only, never grandchildren. Top-level runs carry none of the three, and neither does a run triggered by an event another run emitted: only a runWorkflow call sets this edge. Every run also carries depth, its causal nesting, absent (0) at the top. A runWorkflow child is its parent’s depth + 1, and an emit-triggered run is the emitting run’s + 1 - so a run can sit at depth > 0 with no parentRunId at all.

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).
Inside a workflow, tag a child run - it also inherits the parent’s tags:
Sending tags on an event and the tag.<key> runs filter are both supported in the TypeScript SDK. 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:
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:
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.
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.

Run logs

GET /runs/{id}/logs returns the structured logs a run emitted via ctx.log, oldest first. Each entry is one captured line:
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 order, 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:
Every frame carries kind, seq, ts, and runId; the rest depends on kind: 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.
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.
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.

Run stats

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

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):
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.
parentRunId is deliberately not accepted here, though GET /runs and GET /runs/stats both take it: one run’s handful of children is a set to list or count, not a rate to chart over time.

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):
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):
aiTokens and aiCost are absent when no run in the session made a model call / supplied a cost.