Mobidoo

Runs and results

POST …/runs answers immediately with a receipt; the run itself takes minutes. This page is about what happens after the 202: how you learn the run finished, and what the finished receipt contains.

There are two ways to learn, and they carry the identical receipt:

WebhookPoll
You doSend communication.webhook_url on the create, verify the signature, answer 2xx.Read the run until status is terminal.
You getOne signed POST per run, when it completes or fails.The same receipt, on your schedule.
Costs youOne public HTTPS endpoint.One timer per in-flight run, and read budget.
Availableapi.dev.sume.com and api.sume.com.Everywhere.

Production integrations do both: the webhook is the fast path, and a read of result_url is the backup for the day your endpoint is down. Nothing about the run changes when a delivery fails.

Lifecycle

StatusMeaning
queuedAccepted, not started.
processingThe run is working.
completedFinished. output, artifacts[] and primary_output_url are populated.
failedFinished with an error. error says why; artifacts[] still carries whatever was made.
canceledStopped by POST …/cancel. One l.
skippedNever ran: you sent on_active_run: "skip" and another run was in flight.

next_action on the receipt says what to do: poll_status while queued or processing, retry_later on a skipped run, none on every terminal run. Those are the only three values a Format run emits.

Poll

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

URL on the receiptEndpointReturns
(the receipt itself)GET /v1/format-runs/{run_id}The full receipt at any status. Most integrations poll this one.
status_urlGET /v1/format-runs/{run_id}/statusThe small poll payload: status, next_action, cancelable, expires_at, queue, timestamps and the URLs again. Once terminal it also carries usage and, on a failure, error, but never output, artifacts or primary_output_url.
result_urlGET /v1/format-runs/{run_id}/resultThe full receipt once terminal; 409 run_not_completed with details.status while the run is in flight.
events_urlGET /v1/format-runs/{run_id}/eventsThe phase timeline. See Watch a run progress.
cancel_urlPOST /v1/format-runs/{run_id}/cancelStops the run. See Cancel.

A loop that reads the full receipt and stops on a terminal status:

Three habits keep a poll loop honest:

  • Back off. Long-form video is 15 to 30 minutes of work; polling every second buys nothing and spends read budget. Double the gap up to a minute. A 429 or 503 during the loop is transient: the run is still executing and still spending, so wait and poll again rather than treating it as a failed run.
  • Use expires_at as your ceiling. A non-terminal receipt carries the deadline past which the run is force-finalized as failed: 90 minutes from created_at, or sooner when the run is older than 25 minutes and has been silent for 10. Set your own timeout from it rather than inventing one. It is null once the run is terminal.
  • Read queue if queued lasts. queue.state is waiting while pickup is inside the normal window and runtime_unavailable once the run has waited past it with nothing claiming it; then retry_after_seconds says how long to back off. position is always null: Sume does not publish queue depth. A runtime_unavailable that lasts more than a few minutes is worth a support ticket with the request_id.

In TypeScript, subscribeFormatRun creates the run and runs this loop for you; waitForRun does the loop for a run id you already have.

Webhook

Send communication.webhook_url on the create and Sume POSTs the terminal receipt to it once, when the run completes or fails, on both api.dev.sume.com and api.sume.com. A canceled or skipped run never delivers: cancel answers you directly, and a skipped run is already terminal on the create response.

FieldBranch on it for
eventAlways format.run.terminal for a Format run. Route on it without inspecting the body.
request_id, run_idEqual, and stable across retries: the dedupe key.
statusOK when the run completed, ERROR when it failed.
outcomeok: completed with output. degraded: completed and billed, real media in artifacts[], but output is null because the projection did not match your schema, and output_error says why. error: the run did not complete. Branch here when the question is "did I get usable output".
payloadThe run receipt, byte-identical to data from GET /v1/format-runs/{run_id}, so one handler serves both transports. null only when the receipt was over 1 MiB; then error.code is payload_too_large and error.result_url says where to fetch it.
errornull on OK; otherwise { code, message }, mirroring payload.error.
created_atWhen this delivery body was built. Order deliveries by it; request_id cannot, since it repeats on retries.

Verify every delivery

Every POST carries three headers:

The signature is HMAC-SHA256 over <timestamp>.<raw_body> with your workspace's signing secret, which you read on the dashboard's Webhooks tab or from GET /v1/webhooks/signing-secret (any key with account:read). Verify against the raw bytes before parsing, reject timestamps outside a five-minute window, and compare the fingerprint header with the one shown next to the secret to confirm both sides hold the same one. The Cookbook has complete receivers in Node and Python; in TypeScript the check is one call:

Delivery rules

PropertyValue
WhenOnce per run, on completed or failed. Never on canceled or skipped.
SuccessAny 2xx, within 10 seconds. Record the event durably, answer, then do the work.
RetriesUp to 10 attempts. Backoff is the longer of exponential (30 s × 2^(attempt−1), with jitter) and your Retry-After on a 429/503, capped at one hour.
RedirectsNot followed. A 3xx is a failed attempt, so register the final URL.
URL rulesPublic HTTPS only. Localhost, private ranges, credentials in the URL and plain HTTP are 400 invalid_request at create, and the URL is checked again at delivery time.
DedupeOn request_id; every retry repeats it.

A delivery outcome never changes the run. Ten refused attempts leave you with a failed delivery and a run that is still completed. Fetch it from result_url.

Check what happened to a delivery

Every receipt for a run created with a webhook_url carries a webhook_delivery block:

statusMeans
not_armedThe URL is stored and nothing is scheduled yet: the run is still going.
pending, retryingArmed; next_attempt_at is when the next attempt is due.
deliveredYour endpoint answered 2xx.
failed, exhaustedSume gave up. last_status_code and last_error (our transport error, never your body) say why. The run is unchanged.

To replay a terminal delivery, after fixing your receiver or to test one, call POST /v1/format-runs/{run_id}/webhook/redeliver with formats:write and an empty body. It re-POSTs the current receipt with a fresh timestamp and signature and does not consume one of the automatic ten. 409 webhook_not_configured when the run had no URL, 409 run_not_terminal while it is still running.

The full contract, including the generation-job webhooks that POST /v1/models/… emits on a different event set, is on Run webhooks.

Watch a run progress

status says whether a run is done. GET /v1/format-runs/{run_id}/events says what it is doing:

preparing is everything before the agent has the run in hand; running is the agent working the recipe, which is where the time goes; finalizing is teardown and output harvest. Each entry carries a status (pending, running, done, warning, error, skipped) and a duration_ms when the phase measured itself. Consecutive entries with the same phase and status collapse into one whose at keeps advancing, so at on the last entry is the run's progress clock. If it stops moving for several minutes the run is stalled, not slow, and will be finalized at the bounds above.

It is a phase timeline, not a log stream. Agent output, tool calls and sandbox internals are not published here and will not be. There is no push channel for progress: poll this endpoint, or skip it and take the terminal webhook.

The run receipt

GET /v1/format-runs/{run_id} returns the full shape at any status.

FieldNotes
id, objectarun_…, and format.run.
format{ id, slug, title, version }. id is the opaque skl_…; version is the Format version that actually ran, unchanged by later edits.
status, next_action, cancelableSee Lifecycle. cancelable is true while queued or processing.
trigger{ source: "api", idempotency_key } for every run you create.
created_at, started_at, finished_atThe last two are null until they happen.
expires_at, queueThe deadline and the pickup state. See Poll.
output_schema{ name, strict, source }: which schema shaped output. source is request_override when you sent one.
outputThe structured result. null on every non-terminal status, and null when nothing satisfied output_schema. A failed run still publishes whatever partial result it authored.
output_error{ code, message, details } when output could not be produced. Check it before reading output.
primary_output_key, primary_output_urlThe one thing to show. Both null unless completed.
artifacts[]Every durable file the run generated: { id, type, url, content_type, size_bytes, width, height, duration_ms, checksum_sha256 }. Empty until terminal; populated on failures too.
usage{ currency, billable_amount_usd_micros, generation_spend_cap_usd_micros }. See Reading usage. null when spend could not be read.
error{ code, message }. Non-null only when failed.
skip_reasonSet on a skipped run.
webhook_deliveryDelivery state for the URL you registered, or null if you registered none.
idempotency_hittrue when this receipt is an idempotency replay rather than a new run.
thread_id, previous_run_idThe conversation this turn happened in, and the run it continued. See Continue a run.
modelThe catalog id the orchestrator ran on.
request_idLog it; it is what support asks for.
status_url, result_url, events_url, cancel_urlSee Poll.

Media URLs are durable media.sume.com HTTPS URLs. They do not expire, and they are public to anyone holding the URL, so proxy or copy them if your product needs per-customer access control.

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: the agent is replayed what it produced, so it can redo one part and leave the rest alone. This is how live-commerce integrations retry a single scene without paying for the whole show again.

The run you name must be continuable, which you can read off its own receipt: thread_id is not null, and it either completed or has a non-empty artifacts[]. A failed run that left work behind can be continued; one that left nothing cannot.

RefusalMeans
404 previous_run_not_foundUnknown id, or another owner's run.
400 previous_run_format_mismatchThat run was created on a different Format. Continue it where it started.
409 previous_run_not_terminalIt has not finished. Poll it, then call again.
400 previous_run_not_resumableNothing to continue: no thread_id, or neither completed nor any artifacts. details reports previous_run_status, has_thread and artifact_count. Start a fresh run.

A continuation is a new run: new id, new receipt, its own spend cap, its own single webhook. The original run never changes. Both share thread_id, which is read-only. Continue by naming previous_run_id, never by sending a thread id (that is 400 unknown_parameter). Bind the same output_schema every turn; it is per run, not inherited. artifacts[] on a continued run lists everything the whole conversation generated, while usage stays per run.

Cancel

Needs formats:write, and is idempotent: the current receipt comes back either way, and cancel_effect says which happened. canceled means this call stopped a run in flight; no_op means it had already finished. Generation the run completed before the cancel is billed, and usage reports it.

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

List runs for a Format

Newest first; limit is 1–100 and defaults to 20; GET /v1/formats/{format_id}/runs is the opaque twin. A Format that has never been run over the API returns an empty list, not a 404. The response is a page: pass next_cursor back as cursor until has_more is false. The cursor is opaque and keyset over (created_at, id), so runs created while you page do not shift rows; a cursor that is not ours is 400 invalid_request.

Routes that do not exist

Three paths callers guess at, and what to use instead:

GuessUse
GET /v1/format-runsThere is no cross-Format list. List per Format, or keep your own index keyed by the data.id you stored at create.
GET /v1/formats/{handle}/{slug}/runs/{run_id}Runs are read at /v1/format-runs/{run_id}. The Format path only creates and lists.
GET /v1/format-runs/{run_id}/messagesThe conversation is not published over the API. events_url gives the phase timeline; output and artifacts[] carry the result.

usage

usage.billable_amount_usd_micros is the generation spend attributed to this run: the running total the run's cap is enforced against, counting both reserved and captured amounts. It climbs while the run is in flight and settles when it terminates. It excludes the agent's own LLM turn, so it is not the run's total cost, and it is a receipt figure rather than an invoice: GET /v1/usage is the authoritative billing record. usage is null when spend could not be read at all, which is different from 0.

Errors on the run endpoints

Statuserror.codeWhat to do
401unauthorizedMissing, malformed, revoked or unknown key.
403insufficient_scopeThe key lacks formats:read (reads) or formats:write (cancel, redeliver). Mint a new key.
404format_run_not_foundUnknown run id, or another owner's run. A run you cannot see reads the same as one that does not exist.
409run_not_completedGET …/result before the run is terminal. details.status carries the current status; poll status_url and retry.
429rate_limitedWait retry-after. Polling spends the read budget, which is separate from and far larger than the write one.
503studio_agent_upstream_unavailableA Sume-side outage, not your key. Retry later; the run is still executing.

Every code, with the run-time failures (error and output_error) alongside, is on Errors and spend.

Next