---
title: Bulk runs
description: Queue up to 100 Format runs with a concurrency window — POST …/bulk-runs, poll GET /v1/format-run-queues/{id}, and read each child at GET /v1/format-runs/{run_id}.
---

Leave a list of Format runs overnight without driving fan-out from your laptop. A bulk
request is a **server-side queue of ordinary Format runs**, not a different execution
engine. Each item is the same unit of work as `POST …/runs`: one sandbox, one agent turn,
one [run receipt](/formats/runs).

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.

## Endpoints

| Method | Path | Scope |
|---|---|---|
| `POST` | `/v1/formats/{format_id}/bulk-runs` | `formats:write` |
| `POST` | `/v1/formats/{handle}/{slug}/bulk-runs` | `formats:write` |
| `GET` | `/v1/format-run-queues/{queue_id}` | `formats:read` |

The two POST paths are twins. Prefer `{handle}/{slug}` in new integrations; the opaque
`skl_…` path stays valid forever. Request body, headers, scopes, and the queue receipt
are identical.

There is no public list-queues or cancel-queue endpoint. Cancel a child with
`POST /v1/format-runs/{run_id}/cancel` — see [Cancel](/formats/runs#cancel).

## Auth

Same API-key rules as a single [Format run](/formats/call):

1. Bearer API key (`Authorization: Bearer $SUME_API_KEY`).
2. The key carries `formats:write` to create a queue and `formats:read` to poll it.
3. For a Format owned by a **team workspace**, the key was issued **in that workspace**.
4. Service-account keys cannot create Format runs or bulk queues. They fail with `403
   insufficient_scope` and `details.reason` of `service_account_format_runs_unsupported`.

Keys created before the Format API-call trigger shipped do not carry these scopes. Create
a new key — scopes cannot be added to an existing one. See
[Calling a Format](/formats/call#scopes) and
[Team Formats need a team key](/formats/call#team-formats-need-a-team-key).

| Scope | Needed for |
|---|---|
| `formats:write` | `POST …/bulk-runs` (and single-run create / cancel). |
| `formats:read` | `GET /v1/format-run-queues/{queue_id}` (and Format / run reads). |

## Create a queue

```bash
curl -sS -X POST "https://api.sume.com/v1/formats/chase/product-promo/bulk-runs" \
  -H "Authorization: Bearer $SUME_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "concurrency": 3,
    "items": [
      { "instruction": "clip 1", "input": { "url": "https://example.com/1.jpg" } },
      { "instruction": "clip 2", "input": { "url": "https://example.com/2.jpg" } },
      { "instruction": "clip 3", "input": { "url": "https://example.com/3.jpg" } },
      { "instruction": "clip 4", "input": { "url": "https://example.com/4.jpg" } }
    ]
  }'
```

The opaque twin:

```bash
curl -sS -X POST "https://api.sume.com/v1/formats/skl_.../bulk-runs" \
  -H "Authorization: Bearer $SUME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "concurrency": 3,
    "items": [
      { "instruction": "clip 1" },
      { "instruction": "clip 2" }
    ]
  }'
```

An accepted create returns **`202`** with `{ "data": { …queue } }`. The first
`concurrency` items are already in flight on that receipt when the list is longer than
the window.

### Request body

| Field | Notes |
|---|---|
| `concurrency` | Required integer **1–16**. How many child Format runs stay in flight at once. |
| `items` | Required array, **1–100** entries, in order. Each entry is the same body as [`POST …/runs`](/formats/call#request-body). |
| `idempotency_key` | Optional body spelling of the `Idempotency-Key` header. The header wins when both are sent. |

Unknown top-level fields are rejected. `items` is required — `{ "concurrency": 3 }` is
`400`, not an empty queue.

Each item must name at least one of `instruction`, `input`, `previous_run_id`, or
`attachments` — the same rule as a single run. A bad item fails the **create** (`400
invalid_request`, `details.index`) **before** a queue exists; nothing is dispatched.

The bulk controller owns single-flight. Every item is executed with `on_active_run:
"allow"`. Sending `skip` or `reject` on an item does not stall the window on the first
in-flight run of this Format. Workspace generation concurrency still applies to the
children.

Per-item `communication.webhook_url` is the same as a single run: each child can register
its own terminal webhook. The **queue object has no webhook**. Do not expect a
queue-level callback.

## Queue receipt

`data` is a `format.run_queue`:

```json
{
  "data": {
    "id": "frq_...",
    "object": "format.run_queue",
    "format": {
      "id": "skl_...",
      "slug": "product-promo",
      "title": "Product promo",
      "version": 3
    },
    "concurrency": 3,
    "status": "running",
    "counts": {
      "total": 4,
      "queued": 1,
      "running": 3,
      "completed": 0,
      "failed": 0,
      "canceled": 0
    },
    "items": [
      { "index": 0, "status": "running", "run_id": "run_...", "error": null },
      { "index": 1, "status": "running", "run_id": "run_...", "error": null },
      { "index": 2, "status": "running", "run_id": "run_...", "error": null },
      { "index": 3, "status": "queued", "run_id": null, "error": null }
    ],
    "created_at": "2026-08-24T00:00:00.000Z",
    "updated_at": "2026-08-24T00:00:00.000Z",
    "finished_at": null,
    "status_url": "https://api.sume.com/v1/format-run-queues/frq_..."
  }
}
```

| Field | Notes |
|---|---|
| `id` | Queue id (`frq_…`). |
| `object` | Always `format.run_queue`. |
| `format` | `{ "id", "slug", "title", "version" }`. `id` is the opaque `skl_…`. |
| `concurrency` | The window you sent (1–16). |
| `status` | `queued` / `running` / `completed`. See below. |
| `counts` | `total`, `queued`, `running`, `completed`, `failed`, `canceled`. All required. |
| `items` | One row per submitted item, in the same order. |
| `created_at`, `updated_at` | ISO-8601. |
| `finished_at` | Set when the queue status becomes `completed`; otherwise `null`. |
| `status_url` | `GET /v1/format-run-queues/{id}` — poll this for progress. |

### Queue status

| Status | Meaning |
|---|---|
| `queued` | Nothing has dispatched yet. |
| `running` | The concurrency window is draining the list. |
| `completed` | **Every item is terminal.** Inspect `counts` for failures — `completed` is not "all succeeded". |

`counts.total` is `items.length`. `counts.running` includes items the API still treats as
in flight (a child that has been claimed but not yet given a `run_id` still counts as
`running`).

## Items

| Field | Notes |
|---|---|
| `index` | Zero-based position in the submitted `items` array. |
| `status` | `queued` / `running` / `completed` / `failed` / `canceled`. |
| `run_id` | Child Format run id once dispatched. `null` while queued, and `null` if the item failed before a child run started. Poll `GET /v1/format-runs/{run_id}` for the full receipt. |
| `error` | `{ "code", "message" }` or `null`. |

| Item status | Meaning |
|---|---|
| `queued` | Not started. `run_id` is `null`. |
| `running` | Inside the concurrency window. |
| `completed` | Child run `completed`. Terminal; frees a slot. |
| `failed` | Child run `failed` (or `skipped`, recorded here as `failed`), or the child could not be started. Terminal; frees a slot. |
| `canceled` | Child run was canceled. Terminal; frees a slot. |

A failed item that never started a run still occupies that `index` with `run_id: null`
and `error` set to the create-run failure (`code` / `message` from that attempt, for
example `format_run_failed_to_start`). The rest of the queue continues.

When a child run settles, `error` is:

| Child run | Item `error` |
|---|---|
| `completed` | `null` |
| `failed` | `{ "code": "format_run_failed", "message": "The Format run failed." }` |
| `canceled` | `{ "code": "format_run_canceled", "message": "The Format run was canceled." }` |

Read **why** a child failed on the run receipt (`GET /v1/format-runs/{run_id}`), not only
on the queue item.

## Concurrency window

The server keeps `concurrency` child runs in flight and starts the next queued item as
soon as a slot opens, until the list is drained. This is not client-driven fan-out.

In-flight slots are items whose public status is `running`. `completed`, `failed`, and
`canceled` are terminal and **free a slot**. When an in-flight child hits `completed` or
`failed` (the usual drain path), the next queued item starts immediately so the window
stays full. A `canceled` child does the same.

Create already fills the window: with `concurrency: 3` and 8 items, the `202` receipt
shows three `running` and five `queued`. Completing item 0 starts item 3; the window
stays at three until fewer than three items remain.

The window never exceeds `concurrency`, even if progress is polled or advanced more than
once.

Child runs still go through ordinary Format-run admission (wallet, workspace generation
concurrency, spend caps). A child that fails to start is that **item** `failed`; the
queue create has already returned `202`.

## Poll the queue

Follow `status_url`, or build `GET /v1/format-run-queues/{queue_id}` from `id`:

```bash
curl -sS "https://api.sume.com/v1/format-run-queues/$QUEUE_ID" \
  -H "Authorization: Bearer $SUME_API_KEY"
```

`200` returns the same queue object as create. Use `counts` for a dashboard; use `items`
when you need per-row `run_id` / `error`.

The queue is `completed` when every item is terminal. Then `finished_at` is set. Branch
on `counts.failed` and `counts.canceled` — do not treat queue `completed` as success.

A queue you cannot see reads as one that does not exist: `404 format_run_queue_not_found`
(unknown id, or a queue owned by another user).

## Child runs

Each dispatched item is an ordinary Format run:

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

`status_url` / `result_url` / `events_url` / `cancel_url` on that receipt behave exactly
as on [Runs and results](/formats/runs). The queue does not replace those endpoints; it
adds counts and per-item status on top.

Canceling a child (`POST /v1/format-runs/{run_id}/cancel`) marks that queue item
`canceled` and frees its slot for the next queued item.

## Idempotency

Send `Idempotency-Key` on create (header; body `idempotency_key` is accepted, header
wins). Keys are scoped to one Format.

| Replay | Result |
|---|---|
| Same key, same `{ concurrency, items }` | `202` and the existing queue. |
| Same key, different payload | `409 idempotency_conflict` (`details.queue_id` names the original). |

Unlike a single run, a bulk replay stays **`202`**. The queue object has no
`idempotency_hit` field.

The queue does not grow a separate idempotency-in-use lock beyond what the control plane
already stores for that key. There is no queue-level webhook, and no queue-level
idempotency behaviour beyond the header / body key above.

## Errors

Create (`POST …/bulk-runs`):

| Code | Status | What to do |
|---|---|---|
| `unauthorized` | 401 | Missing, malformed, revoked, or unknown API key. |
| `insufficient_scope` | 403 | The key lacks `formats:write`, or it is a service-account key (`details.reason` is `service_account_format_runs_unsupported`). `next_action` is `authenticate`. Scopes cannot be patched — mint a new key. Missing scopes are never `format_not_found`. |
| `workspace_key_required` | 403 | Team Format, personal key. Use a key created in `details.workspace_id`. |
| `invalid_request` | 400 | `concurrency` is not an integer 1–16; `items` is missing, empty, or longer than 100; an item is not an object; or `items[i]` names none of `instruction` / `input` / `previous_run_id` / `attachments`. `details.index` names a bad item. |
| `format_not_found` | 404 | Unknown, archived, outside your key's workspace, or a team handle you are not a member of. |
| `format_api_trigger_disabled` | 409 | The API call trigger is off for this Format. |
| `format_inactive` | 409 | The Format is inactive. |
| `idempotency_conflict` | 409 | That `Idempotency-Key` was already used with a different bulk payload. |
| `invalid_attachment` / `attachment_not_found` / `attachment_too_large` / `attachment_fetch_failed` | 400 / 413 / 502 | Fired while resolving an item's `attachments` **before** the queue is created — same codes as [Calling a Format](/formats/call#errors). |
| `rate_limited` | 429 | Wait `retry-after` seconds. Create spends the write budget. |
| `studio_agent_upstream_unavailable` | 503 | A Sume-side outage. Retry later. |

Poll (`GET /v1/format-run-queues/{queue_id}`):

| Code | Status | What to do |
|---|---|---|
| `unauthorized` | 401 | Missing or invalid API key. |
| `insufficient_scope` | 403 | The key lacks `formats:read`. `details.required_scope` names it. |
| `format_run_queue_not_found` | 404 | Unknown queue id, or a queue belonging to another owner. |
| `rate_limited` | 429 | Wait `retry-after`. Polling spends the **read** budget, separate from create. |
| `studio_agent_upstream_unavailable` | 503 | Retry later. The queue is still draining. |

A `429` or `503` during a poll loop is transient: the queue keeps working. Back off
rather than treating it as a failed queue.

Wallet / admission failures on a **child** after `202` do not fail the create. That item
becomes `failed` with the create-run error, and the window refills from the remaining
`queued` items.

## End to end

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

QUEUE=$(curl -sS -X POST "https://api.sume.com/v1/formats/chase/product-promo/bulk-runs" \
  -H "Authorization: Bearer $SUME_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
        "concurrency": 3,
        "items": [
          { "instruction": "clip 1", "input": { "url": "https://example.com/1.jpg" } },
          { "instruction": "clip 2", "input": { "url": "https://example.com/2.jpg" } },
          { "instruction": "clip 3", "input": { "url": "https://example.com/3.jpg" } },
          { "instruction": "clip 4", "input": { "url": "https://example.com/4.jpg" } }
        ]
      }')

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

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

echo "$QUEUE" | jq '.data.counts'
# Queue status completed means every item is terminal — inspect counts.failed.
```

Use exponential backoff in production rather than a fixed five-second sleep. Each child
is still minutes of work when the Format makes video; polling the queue every second
buys you nothing and costs you rate limit.

To read a finished child's media, take `run_id` off `items[]` and follow
[Runs and results](/formats/runs).

## Next

- [Calling a Format](/formats/call) — the per-item invoke contract and every submit error
- [Runs and results](/formats/runs) — child receipts, polling, cancelation
- [Structured output](/formats/structured-output) — bind a schema on each item
- [Run webhooks](/agents/run-webhooks) — per-child `communication.webhook_url`, not a queue callback
- [Embed a Format in your product](/cookbooks/embed-a-format) — partner integration around a single run
