---
title: Waiting for runs and jobs
description: subscribeFormatRun creates a Format run and waits for the receipt; waitForRun polls any Format, Action, or Agent run; waitForJob polls a generation job. Prefer webhooks when delivery is on — there is no SSE stream yet.
---

Every Sume run is asynchronous: `POST .../runs` hands back a receipt with a `status_url`,
and you learn the outcome later. A bulk queue is a server-side list of those runs — poll
`GET /v1/format-run-queues/{queue_id}` for counts; the helpers on this page still wait on
one run id. See [Bulk runs](/formats/bulk-runs). In `@sume-com/sdk@0.2.0` the partner-facing Format path
is **`subscribeFormatRun`** (create + wait). Use **`waitForRun`** when you already have a
run id, and **`waitForJob`** for generation jobs, which are a separate surface. Prefer
[run webhooks](/agents/run-webhooks) when you can skip the wait entirely.

There is **no SSE event stream** today, so "subscribe" means create-then-poll. `onStatus`
reflects status polling (including a richer snapshot with `next_action`, timestamps, and
`cancelable`), not a live log feed. On a Format run you can also poll the **phase
timeline** — see [Watching phases while you wait](#watching-phases-while-you-wait).

## Format runs: `subscribeFormatRun`

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

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

const run = await subscribeFormatRun({
  client,
  path: { handle: "acme", slug: "product-promo" },
  idempotencyKey: "order-8823-promo-v1",
  body: {
    input: { product_url: "https://shop.example.com/p/8823" },
    generation_spend_cap_usd: 3,
    // Or skip the wait and take the push:
    // communication: { webhook_url: "https://partner.example/hooks/sume" },
  },
  onStatus: (status, snapshot) => console.log(status, snapshot.next_action),
});

if (run.status === "completed") {
  console.log(run.primary_output_url);
} else {
  console.error(run.status, run.error);
}
```

| Option | Default | Notes |
|---|---|---|
| `path` | — | Vanity `{ handle, slug }` or `{ format_id }`. |
| `body` | — | Same body as `createFormatRun*` (input, caps, schema, attachments, …). |
| `idempotencyKey` | — | Sent as `Idempotency-Key`. A replay of a finished run returns immediately. |
| `timeout` | **20 minutes** | Longer than `waitForRun`'s 10 — video Formats routinely run 10–20. |
| `pollInterval` | 2 seconds | Gap between status reads. |
| `signal` | — | Aborts the wait and the in-flight request. |
| `timeline` | `false` | Also read the phase timeline on every poll and hand it to `onStatus` as `snapshot.timeline`. |
| `onStatus` | — | `(status, snapshot)` on every status read, including the terminal one. |
| `onCreated` | — | Called once with the accepted run, before polling starts. |

It resolves for **any** terminal status. It **throws** only when the create call itself
is refused (for example `403 workspace_key_required` on a team Format called with a
personal key) — there is no run to wait for.

Field-by-field, the receipt is documented in [Runs and results](/formats/runs).

## Already have a run id: `waitForRun`

Use this for Action / Agent Completion runs, or when you created the Format run yourself
and only need the poll loop.

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

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

const run = await waitForRun(runId, {
  client,
  family: "format",
  timeout: 15 * 60_000,
  pollInterval: 2_000,
  signal: AbortSignal.timeout(20 * 60_000),
  onStatus: (status, snapshot) => console.log(status, snapshot.next_action),
});
```

| Option | Default | Notes |
|---|---|---|
| `family` | — | **Required.** `"format"`, `"action"`, or `"agent"`. |
| `client` | module default | The client from `createSumeClient()`. Pass it — the module default has no base URL or key. |
| `timeout` | 10 minutes | Exceeding it throws `SumeRunTimeoutError`. |
| `pollInterval` | 2 seconds | Gap between status reads. |
| `signal` | — | Aborts the wait and the in-flight request; rejects with the signal's reason. |
| `timeline` | `false` | Format runs only. Also read the phase timeline on every poll. |
| `onStatus` | — | `(status, snapshot)` on every status read, including the terminal one. |

**`family` is required and cannot be inferred.** A run id does not say which surface it
belongs to, and the three families live behind three different URL prefixes
(`/v1/format-runs/…`, `/v1/action-runs/…`, `/v1/agent-runs/…`). It is also what types the
return value: `family: "format"` resolves as `PublicFormatRun`.

The deadline is checked **before** sleeping, not after. A caller who asks for a 5-second
timeout hears about it in 5 seconds, rather than 5 seconds plus one whole poll interval.

## Watching phases while you wait

`status` tells you a Format run is `processing`. It does not tell you *what* it is doing,
which for a fifteen-minute video run is most of what you want to know. The **phase
timeline** does — `preparing`, `running`, `finalizing`, each with a timestamp and a status.

Pass `timeline: true` and it arrives on the `onStatus` snapshot:

```ts
const run = await subscribeFormatRun({
  client,
  path: { handle: "acme", slug: "product-promo" },
  body: { input: { product_url: "https://shop.example.com/p/8823" } },
  timeline: true,
  onStatus: (status, snapshot) => {
    const phase = snapshot.timeline?.at(-1);
    console.log(status, phase?.phase, phase?.status);
  },
});
```

Or read it directly, for a run you are not waiting on:

```ts
import { getFormatRunTimeline } from "@sume-com/sdk";

const timeline = await getFormatRunTimeline(runId, { client });
// [{ at: "2026-08-05T…", phase: "running", status: "running", duration_ms: null }, …]
```

Three things to know:

- **It is a phase timeline, not a log stream.** Agent output, tool calls and sandbox
  internals are never published. See [Watch a run progress](/formats/runs#watch-a-run-progress).
- **`timeline: true` doubles the request rate of the wait.** Reads have their own
  rate-limit budget, so this cannot 429 your run creates — but it is still twice the
  requests for the same run, so it is off by default.
- **A timeline read that fails does not end the wait.** The run is still executing and
  still spending; the failure is reported through `onTransientError` and the previous
  timeline is kept.

Format runs only — `timeline: true` is ignored for `family: "action"` and `"agent"`,
whose receipts report `events_url: null` because they have no events route.

## Generation jobs: `waitForJob`

Runs and jobs are different surfaces. `POST /v1/image-1.0/generate`,
`/v1/video-1.0/generate`, the Avatar routes, and the `/v1/models/sume/…/runs`
aliases all create **jobs** at `/v1/jobs/:id`, not runs — so they need
`waitForJob`, not `waitForRun`. A job id and a run id are not interchangeable.

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

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

const { data: submitted, error } = await generateVideoV1({
  client,
  headers: { "idempotency-key": crypto.randomUUID() },
  body: { prompt: "Slow push-in on a ceramic mug", mode: "async" },
});
if (error) throw new Error(JSON.stringify(error));

const job = await waitForJob(submitted!.data.request_id, {
  client,
  onStatus: (status, snapshot) => console.log(status, snapshot.next_action),
});

console.log(job.status, job.result.artifacts);
```

| Option | Default | Notes |
|---|---|---|
| `client` | module default | The client from `createSumeClient()`. Pass it — the module default has no base URL or key. |
| `timeout` | **20 minutes** | Longer than `waitForRun`'s 10 — video and avatar-video jobs routinely run minutes. Exceeding it throws `SumeJobTimeoutError`. |
| `pollInterval` | 2 seconds | A **floor**. The status payload's `next_poll_after_seconds` wins when it asks for a longer gap. |
| `signal` | — | Aborts the wait and the in-flight request. |
| `onStatus` | — | `(status, snapshot)` on every status read, including the terminal one. |

Submit with **`mode: "async"`** (or omit `mode`). The server-side `sync` and
`subscribe` modes are the same bounded wait capped at 30 seconds, which is a
budget for the HTTP request rather than for the job — see
[Communication modes](/workflows/jobs-and-results). `waitForJob` is the
client-side wait that can outlast it.

It resolves with the job record read from `/v1/jobs/:id`, not from
`/v1/jobs/:id/result` — `/result` answers `409 job_not_completed` for failed and
canceled jobs, and there would be nothing to hand back. Read `status`, `result`,
and `error` off the record. Errors mirror the run helpers:
`SumeJobTimeoutError` and `SumeJobRequestError`, both carrying `jobId`.

A timeout does not cancel the job. It keeps running and still bills; store the
job id and read it back later with `getApiJob`, or cancel it with
`cancelApiJob`.

## Terminal is not the same as successful

Both run helpers resolve for **any** terminal status — `completed`, `failed`, `canceled`,
`skipped`. A failed run is a result you asked for, not an exception, so read `status` and
`error` off the receipt exactly as a webhook handler would:

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

switch (run.status) {
  case "completed":
    return attachOutputs(run);
  case "failed":
    // `unattended_blocked` means the run hit a gate it could not clear
    // without a human. The message is written to be shown.
    return showError(run.error);
  case "canceled":
  case "skipped":
    return noop();
}
```

`skipped` is worth a branch of its own: it means a run was already in flight and
you passed `on_active_run: "skip"` (Format runs allow concurrency by default).
See [Runs and results](/formats/runs) for the whole lifecycle.

## Errors they throw

Unlike the generated operations — which resolve with `{ data, error }` — these helpers
throw, because a poll loop has nowhere to put a non-result.

| Error | When |
|---|---|
| `SumeRunTimeoutError` | `timeout` elapsed first. Carries `runId` and `lastStatus`. |
| `SumeRunRequestError` | Create refused, or a read failed and was not transient. Carries `runId` (or `"(not created)"`) plus everything on `SumeApiError`. |
| the `signal`'s reason | You aborted. |

`SumeRunRequestError` extends `SumeApiError`, so the envelope is available as typed fields
rather than something to dig out of `body`:

```ts
import { SumeInsufficientCreditsError, SumeRunRequestError } from "@sume-com/sdk";

try {
  const run = await subscribeFormatRun({ client, path, body });
} catch (error) {
  if (error instanceof SumeInsufficientCreditsError) {
    return topUpAndAlert(error.requestId); // next_action: "add_funds"
  }
  if (error instanceof SumeRunRequestError) {
    log.error({ code: error.code, requestId: error.requestId, retryable: error.retryable });
  }
  throw error;
}
```

`SumeApiError` subclasses: `SumeAuthenticationError` (401), `SumeInsufficientCreditsError`
(402), `SumePermissionError` (403), `SumeNotFoundError` (404), `SumeConflictError` (409),
`SumeRateLimitError` (429), `SumeServerError` (5xx). Every one carries `code`, `requestId`,
`retryable`, `retryAfterSeconds`, `nextAction`, `details`, and the raw `body`.

## Transient failures do not end the wait

A `429` or a `5xx` on a status read means the *read* failed, not the run. The run is still
executing and still spending, so the helpers back off and poll again rather than throwing —
losing the handle to a live run is far more expensive than waiting another second.

Two layers do this, and both are on by default:

| Layer | Default | Option |
|---|---|---|
| `createSumeClient` retries `408`/`429`/`5xx` and transport failures | 2 retries, exponential backoff + jitter, honours `retry-after` | `maxRetries`, `timeout` |
| `waitForRun` tolerates consecutive transient read failures | 6 | `maxTransientFailures`, `onTransientError` |

A `POST` is only retried when it carries an `Idempotency-Key` — without one, a replay would
start and bill a second run. `subscribeFormatRun` generates one for you unless you pass your
own (or `null`).

Polls are jittered: reads have their own rate-limit budget, but several clients started
together would otherwise stay in phase and hit that ceiling as a group.

```ts
import { SumeRunTimeoutError, waitForRun } from "@sume-com/sdk";

try {
  const run = await waitForRun(runId, { client, family: "format" });
} catch (error) {
  if (error instanceof SumeRunTimeoutError) {
    // The run is still going. Nothing was lost — read it later from `result_url`.
    await markPending(error.runId, error.lastStatus);
  } else {
    throw error;
  }
}
```

A timeout does **not** cancel the run. The run keeps going; you have only stopped
watching. Store the run id and pick it up later with `getFormatRun`, or cancel it
explicitly with `cancelFormatRun`.

## Prefer a webhook where you can

Polling is one timer and one open request per run in flight, for runs that routinely take
minutes. [Run webhooks](/agents/run-webhooks) deliver the identical receipt without any of
that, and [`verifyWebhook`](/sdk/webhooks) is the receiver-side half.

Polling is the right tool when you are inside a job that can afford to block, when you
are prototyping, or while webhook delivery is still off for your environment. Build the
receiver now and keep `subscribeFormatRun` / `waitForRun` as the fallback.

## Next

- [Verifying webhooks](/sdk/webhooks) — the push path's receiver check
- [Runs and results](/formats/runs) — the receipt, field by field
- [Bulk runs](/formats/bulk-runs) — queue many runs; poll `GET /v1/format-run-queues/{id}`
- [Embed a Format in your product](/cookbooks/embed-a-format) — the whole partner integration
