---
title: Runs and results
description: The Format run receipt field by field — lifecycle, polling, next_action, usage, listing, and cancelation.
---

A Format run is asynchronous. `POST .../runs` hands you a receipt immediately; the receipt is
how you learn what happened afterwards.

One run is still one unit of work. A bulk request is a server-side **queue of those runs**,
not a new execution engine — see [Bulk runs](/formats/bulk-runs). Poll the queue at
`GET /v1/format-run-queues/{queue_id}` (the queue receipt's `status_url`) for `counts` and
per-item status. Each child still uses the Format-run endpoints on this page.

Polling always works. [Run webhooks](/agents/run-webhooks) push the same receipt
without a loop on both `api.dev.sume.com` and `api.sume.com`.

Exact request and response schemas come from live OpenAPI
(`https://api.sume.com/reference/json`). The tables here are a readable summary, not a second
schema.

## Lifecycle

```text
queued -> processing -> completed | failed | canceled | skipped
```

| Status | Meaning |
|---|---|
| `queued` | Accepted, not started. |
| `processing` | The run is working. |
| `completed` | Finished. `output` and `artifacts` are populated. |
| `failed` | Finished with an error. |
| `canceled` | Stopped by a cancel request. |
| `skipped` | Never ran, because another run was already in flight. |

`canceled` is spelled with one `l`. These are the API's own status names, not the internal
job statuses — do not assume job-side strings transfer.

## Bulk queues

`POST /v1/formats/{format_id}/bulk-runs` (and the vanity twin) returns a queue object
(`object: "format.run_queue"`, id `frq_…`). Progress for that list is
`GET /v1/format-run-queues/{queue_id}` — not another Format-run poll. Queue `status`
`completed` means every item is terminal; inspect `counts.failed` / `counts.canceled`.
Child receipts stay at `GET /v1/format-runs/{run_id}`. Full contract:
[Bulk runs](/formats/bulk-runs).

## Four read endpoints

The receipt carries its own URLs. Follow them rather than building paths.

| Field | Endpoint | Returns |
|---|---|---|
| `status_url` | `GET /v1/format-runs/{run_id}/status` | `id`, `status`, `started_at`, `finished_at`, `next_action`, `cancelable`, `expires_at`, `queue`, and the URLs again — the poll payload. Once the run is terminal it also carries `usage`, and `error` when it failed, so the poll that sees `failed` already says why. |
| `result_url` | `GET /v1/format-runs/{run_id}/result` | The full receipt, or `409 run_not_completed` with the current status in `details.status` while the run is in flight. |
| `events_url` | `GET /v1/format-runs/{run_id}/events` | The run's phase timeline — what it is doing and since when. |
| `cancel_url` | `POST /v1/format-runs/{run_id}/cancel` | The current receipt. |
| — | `GET /v1/format-runs/{run_id}` | The full receipt at any status. |

```bash
curl -sS "https://api.sume.com/v1/format-runs/$RUN_ID/status" \
  -H "Authorization: Bearer $SUME_API_KEY"
```

```json
{
  "data": {
    "id": "run_...",
    "status": "processing",
    "started_at": "2026-07-31T09:00:01.000Z",
    "finished_at": null,
    "next_action": "poll_status",
    "cancelable": true,
    "expires_at": "2026-07-31T10:30:00.000Z",
    "status_url": "https://api.sume.com/v1/format-runs/arun_.../status",
    "result_url": "https://api.sume.com/v1/format-runs/arun_.../result",
    "cancel_url": "https://api.sume.com/v1/format-runs/arun_.../cancel"
  }
}
```

The three URLs are echoed so a handle to `status_url` alone is enough to reach the result and the
cancel. You never need to build a path.

## Watch a run progress

`status` answers *whether* a run is done. It does not answer *what it is doing*, which is the
question that matters during a ten-minute run. `GET /v1/format-runs/{run_id}/events` answers that:

```bash
curl -sS "https://api.sume.com/v1/format-runs/$RUN_ID/events" \
  -H "Authorization: Bearer $SUME_API_KEY"
```

```json
{
  "data": [
    { "at": "2026-07-31T09:00:00.000Z", "phase": "preparing",  "status": "done",    "duration_ms": 1840 },
    { "at": "2026-07-31T09:04:12.000Z", "phase": "running",    "status": "running", "duration_ms": null },
    { "at": "2026-07-31T09:09:57.000Z", "phase": "finalizing", "status": "done",    "duration_ms": 620 }
  ]
}
```

| Phase | Meaning |
|---|---|
| `preparing` | Sume is getting the run ready. Nothing of yours is executing yet. |
| `running` | The agent is working the Format. This is where the time goes. |
| `finalizing` | Teardown and output harvest. The result is nearly ready. |

Each entry carries a `status` (`pending`, `running`, `done`, `warning`, `error`, `skipped`) and a
`duration_ms` when the phase measured itself. Consecutive entries reporting the same phase and
status are collapsed into one, so a long phase is a single entry whose `at` keeps advancing.

**`at` on the last entry is the run's progress clock.** If it is advancing, the run is working; if
it has not moved in several minutes, the run is stalled rather than slow, and it will be finalized
at the bounds below.

An empty array is a normal answer — a run that has not started yet has no phases, and a `skipped`
run never will.

### What this is not

It is not a log stream. Agent output, tool calls, sandbox identifiers and internal stage messages
are not published here and will not be. If you need the model's reasoning or its intermediate work,
that is not something the API exposes; read `output` and `artifacts` off the terminal receipt.

There is also no push channel — no SSE, no WebSocket. Poll this endpoint, or register a
[run webhook](/agents/run-webhooks) for the terminal event and skip polling entirely.

## How long a run can take

A run is not allowed to hang forever, and `expires_at` on a non-terminal receipt is the deadline:

| Rule | Default |
|---|---|
| Hard ceiling, regardless of activity | **90 minutes** from `created_at` |
| A run making no progress finalizes sooner: older than this **and** silent for the idle window | 25 minutes + 10 minutes idle |

Past either bound the run is force-finalized as `failed` — it does not sit in `processing`
indefinitely, and it stops spending. `expires_at` is `null` once the run is terminal.

Treat `expires_at` as an upper bound rather than an estimate. Most Formats finish in well under
it; Live Commerce runs are the long tail at ten to twenty minutes. If you are waiting
programmatically, use it as the ceiling for your own timeout rather than inventing one.

## Is anything running my run?

`status: "queued"` used to answer two different questions with one word: *the runtime is
about to pick this up* and *nothing is running*. The `queue` block on the receipt (and on
the `/status` poll payload) separates them.

```json
{
  "queue": {
    "state": "runtime_unavailable",
    "reason": "runtime_not_picking_up",
    "position": null,
    "retry_after_seconds": 30
  }
}
```

| `state` | Means |
|---|---|
| `waiting` | Queued, and pickup is still inside the normal window. Keep polling. |
| `runtime_unavailable` | Queued well past that window with nothing having claimed it. Back off by `retry_after_seconds`; do not submit a second run for the same intent. |
| `processing` | The runtime started it. |
| `done` | The run is terminal. |

Two things this deliberately does **not** tell you:

- **`position` is always `null`.** Sume does not publish queue depth. It is not a number we
  can compute honestly, and a wrong position is worse than none. The field is declared so
  you can branch on it rather than discover its absence in production.
- **`runtime_not_picking_up` says nobody claimed the run, not why.** From the API's side
  that is genuinely all that is known, and inventing a cause would be worse than saying so.

If you see `runtime_unavailable` for more than a few minutes, that is worth a support
ticket with the `request_id`.

## The run receipt

`GET /v1/format-runs/{run_id}` returns the full shape.

| Field | Notes |
|---|---|
| `id`, `object` | `object` is `format.run`. |
| `format` | `{ "id", "slug", "title", "version" }`. `id` is the opaque `skl_…`. |
| `status` | See the table above. |
| `trigger` | `{ "source": "cron" \| "manual" \| "api", "idempotency_key" }`. API runs are `api`. |
| `created_at`, `started_at`, `finished_at` | The last two are `null` until they happen. |
| `output_schema` | `{ "name", "strict", "source" }` — which schema shaped `output`. See [Structured output](/formats/structured-output#bind-a-schema). |
| `output` | The structured result. `null` on every non-terminal status, and `null` whenever the run produced nothing that satisfies `output_schema` — **not** merely because the run failed. A `failed` run publishes whatever partial result it did author; see [When output cannot be produced](/formats/structured-output#when-output-cannot-be-produced). |
| `output_error` | `{ "code", "message", "details" }` when the projection could not produce `output`. See [When output cannot be produced](/formats/structured-output#when-output-cannot-be-produced). |
| `primary_output_key` | The key in `output` whose URL is the one thing to show. `null` unless `completed`. |
| `primary_output_url` | Resolved URL for `primary_output_key`. `null` unless `completed`. |
| `artifacts` | Every durable file the run generated. Empty until the run is terminal. |
| `usage` | `{ "currency": "USD", "billable_amount_usd_micros", "generation_spend_cap_usd_micros" }`, or `null` when spend could not be read. |
| `error` | `{ "code", "message" }`. Non-null only when `status` is `failed`. |
| `skip_reason` | Populated on a `skipped` run, otherwise `null`. |
| `request_id` | Log this. It is what support will ask for. |
| `status_url`, `result_url`, `cancel_url` | See above. |
| `events_url` | The run's phase timeline — see [Watch a run progress](#watch-a-run-progress). Non-null on Format runs; Action and Agent Completion receipts still report `null`. |
| `expires_at` | Deadline past which the run is force-finalized. `null` once terminal — see [How long a run can take](#how-long-a-run-can-take). |
| `queue` | Whether anything is running this yet — see [Is anything running my run?](#is-anything-running-my-run). |
| `cancelable` | `true` while `queued` or `processing`. |
| `next_action` | Recommended next step — see below. |
| `webhook_delivery` | Delivery state for the callback you registered, or `null` if you registered none — see below. |
| `idempotency_hit` | `true` when this receipt is an idempotency replay rather than a new run. |
| `thread_id` | The agent conversation this run's turn happened in. Runs that continue one another share it. `null` on a `skipped` run. |
| `previous_run_id` | The run this one continued, echoed from the request. `null` on a run that started fresh. |

`artifacts[]` is drawn from the same job ledger that fills `output`, so the two always agree.
Media fields are durable `media.sume.com` HTTPS URLs — they do not expire.

## Continue a run

A Format run is one **agent turn**. Send `previous_run_id` on a new
`POST .../runs` and the next turn continues the same conversation instead of
starting over: the agent is replayed what it produced last time, so it can
revise part of a result and leave the rest alone.

The run you name must be **continuable**, which is two conditions you can read
straight off its own receipt:

- `thread_id` is not `null` — there is a conversation to rejoin; and
- it `completed`, **or** its `artifacts[]` is non-empty.

So a `failed` or `canceled` run **can** be continued when it left work behind.
A live-commerce show that produced 20 of 40 clips and then failed is incomplete,
not absent: the clips are on the thread, they were paid for, and the next turn
can fill the gaps rather than regenerate the lot. What stays refused is a
predecessor that left *nothing* — there the continuation would spend a turn
rediscovering that, and starting fresh is both cheaper and clearer.

```bash
curl -sS -X POST "https://api.sume.com/v1/formats/$FORMAT_ID/runs" \
  -H "Authorization: Bearer $SUME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "previous_run_id": "arun_...",
        "instruction": "Redo the third clip. Leave everything else exactly as it is.",
        "output_schema": { "...": "the same schema you bound the first time" }
      }'
```

**A continuation is a new run.** New id, new receipt, its own
`generation_spend_cap_usd`, its own single terminal webhook. Poll the id you
just received — the old run stays `completed` forever and will never change
again.

Both runs report the same `thread_id`, which is how you tell that two run ids
are two turns of one conversation. `thread_id` is read-only: continue by naming
`previous_run_id`, never by sending a thread id back.

Bind the **same `output_schema`** you bound on the first call. The schema is
per-run, not inherited, so omitting it gets you the default shape rather than
the one your backend expects.

| Refusal | Means |
|---|---|
| `404 previous_run_not_found` | Unknown id, or a run belonging to another owner. |
| `400 previous_run_format_mismatch` | That run was created on a different Format. A run can only be continued where it started. |
| `409 previous_run_not_terminal` | It has not finished. Poll it, then call again. |
| `400 previous_run_not_resumable` | It left nothing to continue: no `thread_id` (a `skipped` run never started one), or it did not complete *and* has no `artifacts[]`. `details` reports `previous_run_status`, `has_thread` and `artifact_count` so you can tell which. Start a fresh run. |

**A continuation is a new run.** It does not inherit the predecessor's outcome:
continuing a `failed` run does not repair that run's receipt, which stays
`failed` forever. You get a new id, and it succeeds or fails on its own.

Two honest details. `artifacts[]` on a continued run lists everything the whole
conversation has generated, not only this turn's — the media ledger is scoped to
the conversation. And `usage` stays per-run, so each turn reports only what that
turn spent.

Whether continuing does anything useful is up to the Format. A Format whose
recipe has no notion of revising its own output will simply run again.

## Branch on `next_action`

Receipts emit only these three values:

| `next_action` | When | Do |
|---|---|---|
| `poll_status` | `queued` or `processing` | Keep polling with backoff. |
| `retry_later` | `skipped` | You asked for Format-level single-flight (`on_active_run: "skip"`) and another run was active; try again. |
| `none` | Every terminal run — `completed`, `failed`, `canceled` | Nothing left to fetch. On a failure, the reason is already on this receipt under `error` and `output_error`. |

Do not expect `fix_input`, `contact_support`, or `inspect_events` on Format run
receipts. The first two belong to the generation-job surface. `inspect_events`
stays retired even now that [an events route exists](#watch-a-run-progress): the
timeline tells you what a run is *doing*, and by the time `next_action` is set
the run is terminal and the reason is already on the receipt you hold. Read the
timeline while you wait, not after.

## Check webhook delivery

If you created the run with `communication.webhook_url`, every receipt carries a
`webhook_delivery` block telling you what happened to that callback. It is `null` when the
run was created without one.

```json
{
  "webhook_delivery": {
    "url": "https://partner.example/hooks/sume",
    "event_type": "format.run.terminal",
    "status": "delivered",
    "attempts": 1,
    "max_attempts": 10,
    "next_attempt_at": null,
    "last_attempt_at": "2026-08-03T09:00:04.000Z",
    "delivered_at": "2026-08-03T09:00:04.000Z",
    "last_status_code": 200,
    "last_error": null,
    "signature_version": "sume-v1"
  }
}
```

| `status` | Means |
|---|---|
| `not_armed` | The URL is stored but nothing is scheduled — either the run has not gone terminal yet, or run-webhook delivery is off in this environment. |
| `pending` | Armed and waiting for its first attempt. |
| `retrying` | An attempt failed; `next_attempt_at` is when the next one is due. HTTP 429 honours `Retry-After` (capped at 1h) on top of exponential backoff. |
| `delivered` | Your endpoint returned 2xx. |
| `failed` / `exhausted` | We gave up (10 attempts). Read `last_status_code` and `last_error`. A 429 here is the inbox rate-limiting us — the run itself is unchanged; poll `result_url`. |

`not_armed` means the URL is stored but nothing is scheduled yet (usually the run
has not gone terminal). After terminal, expect `pending` → `delivered` (or a
retry/failure status). Polling remains a valid backup.

`last_error` is our own transport error (a timeout, a connection failure, a non-2xx status),
never your response body.

## Reading `usage`

`usage.billable_amount_usd_micros` is the **generation** spend attributed to this run — the
same running total the run's `generation_spend_cap_usd_micros` is enforced against, counting
both reserved and captured amounts. It climbs while the run is in flight and settles when the
run terminates.

Two things it is not. It excludes the agent's own LLM turn, which bills a separate wallet, so
it is not the run's total cost. And it is a receipt figure, not an invoice —
[`GET /v1/usage`](/dashboard/usage) remains the authoritative billing record.

`usage` itself is `null` when spend could not be read at all. That is distinct from `0`,
which means the run really has spent nothing yet.

## Which version ran

`format.version` records the Format version that produced the run. Editing a Format bumps its
version and does not touch any receipt already issued, so an old run always tells you what it
actually executed.

## List runs for a Format

```bash
curl -sS "https://api.sume.com/v1/formats/$FORMAT_ID/runs?limit=20" \
  -H "Authorization: Bearer $SUME_API_KEY"
```

Newest first. `limit` accepts 1–100 and defaults to 20. A Format whose API trigger has never
been used returns an empty list, not a 404.

The response is a page:

```json
{
  "data": [{ "id": "arun_...", "object": "format.run" }],
  "has_more": true,
  "next_cursor": "MjAyNi0wOC0wM1QwOTowMDowMC4wMDBafGFydW5fMQ"
}
```

To walk the whole history, pass `next_cursor` back as `cursor` until `has_more` is `false`:

```bash
curl -sS "https://api.sume.com/v1/formats/$FORMAT_ID/runs?limit=20&cursor=$NEXT_CURSOR" \
  -H "Authorization: Bearer $SUME_API_KEY"
```

The cursor is opaque — pass it back verbatim, never parse or construct one. It is a keyset
over `(created_at, id)`, so runs created while you page do not shift rows onto a page you
already read. A cursor that is not one of ours is rejected with `400 invalid_request` rather
than silently restarting from the newest run.

The vanity path works the same way: `GET /v1/formats/{handle}/{slug}/runs`.

## Cancel

```bash
curl -sS -X POST "https://api.sume.com/v1/format-runs/$RUN_ID/cancel" \
  -H "Authorization: Bearer $SUME_API_KEY"
```

Requires `formats:write`, and is idempotent. Canceling a run that already reached a terminal
status is a no-op; the current receipt comes back either way. `cancel_effect` on the response
says which happened:

```json
{ "data": { "id": "run_...", "status": "canceled" }, "cancel_effect": "canceled" }
```

| `cancel_effect` | Meaning |
|---|---|
| `canceled` | This call stopped a run that was still in flight. |
| `no_op` | The run had already finished; nothing changed. |

A canceled run **never delivers a webhook**. If your integration is webhook-only, a cancel is the
one path where nothing will arrive; fall back to the receipt this call returns.

## Errors on the run endpoints

| Code | Status | What to do |
|---|---|---|
| `unauthorized` | 401 | Missing, malformed, revoked, or unknown API key. |
| `insufficient_scope` | 403 | The key lacks `formats:read` (reads) or `formats:write` (cancel). `details.required_scope` names it. `next_action` is `authenticate`. Scopes cannot be patched — mint a new key. |
| `format_run_not_found` | 404 | Unknown run id, or a run owned by another user. A run you cannot see reads the same as one that does not exist. |
| `run_not_completed` | 409 | `GET …/result` before the run is terminal. `details.status` carries the current status; poll `status_url` and retry. |
| `rate_limited` | 429 | Wait `retry-after` seconds. Polling spends the read budget, which is separate from — and far larger than — the one run creates spend. See [Rate limits](/formats/call#rate-limits). |
| `studio_agent_upstream_unavailable` | 503 | A Sume-side outage, not your key. Retry later. |

A `429` or a `503` during a poll loop is **transient**: the run is still executing. Back off and
poll again rather than treating it as a failed run — abandoning the loop does not stop the run or
its spend.

## End to end

```bash
export SUME_API_KEY="sume_live_..."

RUN=$(curl -sS -X POST "https://api.sume.com/v1/formats/chase/product-promo/runs" \
  -H "Authorization: Bearer $SUME_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"input":{"product_url":"https://example.com/p"}}')

RUN_ID=$(echo "$RUN" | jq -r '.data.id')
STATUS=$(echo "$RUN" | jq -r '.data.status')

while [ "$STATUS" = "queued" ] || [ "$STATUS" = "processing" ]; do
  sleep 5
  STATUS=$(curl -sS "https://api.sume.com/v1/format-runs/$RUN_ID/status" \
    -H "Authorization: Bearer $SUME_API_KEY" | jq -r '.data.status')
done

if [ "$STATUS" = "completed" ]; then
  curl -sS "https://api.sume.com/v1/format-runs/$RUN_ID/result" \
    -H "Authorization: Bearer $SUME_API_KEY" \
    | jq -r '.data.primary_output_url'
else
  echo "run ended as $STATUS"
fi
```

Use exponential backoff in production rather than a fixed five-second sleep. A run that makes
video is minutes of work; polling every second buys you nothing and costs you rate limit.

In TypeScript, prefer [`subscribeFormatRun`](/sdk/runs) for Formats (create + wait).
Use [`waitForRun`](/sdk/runs) when you already have a run id. Prefer a
[webhook](/agents/run-webhooks) when delivery is on — there is no push channel,
so everything else is a poll.

```ts
import { createSumeClient, subscribeFormatRun } from "@sume-com/sdk";

const client = createSumeClient({ apiKey: process.env.SUME_API_KEY! });

const run = await subscribeFormatRun({
  client,
  path: { handle: "chase", slug: "product-promo" },
  idempotencyKey: crypto.randomUUID(),
  body: { input: { product_url: "https://example.com/p" } },
});

// Resolves on any terminal status, so branch on it — a failed run is a result.
if (run.status === "completed") console.log(run.primary_output_url);
```

## Next

- [Structured output](/formats/structured-output) — shaping `output`, and what to do when it is null
- [Calling a Format](/formats/call) — the invoke contract and every submit error
- [Bulk runs](/formats/bulk-runs) — a queue of these runs, plus `GET /v1/format-run-queues/{id}`
- [Run webhooks](/agents/run-webhooks) — the terminal receipt, pushed instead of polled
- [Waiting for runs](/sdk/runs) — `subscribeFormatRun`, `waitForRun`, and failure modes
