Executions
An execution (a “run”) is one invocation of a workflow with a specific set of inputs. This is where the work happens. This document covers submitting an execution, the input-encoding rules in full, tracking progress by polling, reading outputs, cancelling, and exporting results as a ZIP.
Executions are asynchronous: you submit, get an ID back immediately, and the generation runs in the background while you poll.
1. Submit an execution
Section titled “1. Submit an execution”POST {BASE_URL}/api/v1/executionsRequest body:
| Field | Type | Required | Description |
|---|---|---|---|
workflow |
string | ✅ | The workflow to run (wf_…). |
inputs |
object | – | Input values keyed by port/field name (see §2). |
version |
integer | – | Pin a specific workflow version. Omit for the latest published version. |
interface |
string | – | A custom interface id to run through (03-workflows §5). Omit for the default interface. |
admission_limits |
object | – | Optional, recommended operational bounds for fan-out whose size is known only at runtime (see §1.2). |
For request tracing, send an X-Correlation-ID header with your own tracking ID: it is
propagated through the whole execution pipeline (logs, internal messaging) and echoed back in the
X-Correlation-ID response header of every API call.
To make submits safe to retry, send an optional Idempotency-Key header (see
§1.1).
curl -s -X POST "$BASE_URL/api/v1/executions" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -H "X-Correlation-ID: order-10472" -d '{ "workflow": "'"$WF"'", "inputs": { "block_title": { "value": "\"Spring in Bloom\"" }, "product_image_0": { "asset_path": "uploads/ws-12/ab/product-hero.jpg" } } }'A successful submit returns HTTP 202 Accepted with the execution object (status pending or
running, no outputs yet):
{ "id": "run_b4c8d3e29f5a4b6c8d1e2f3a4b5c6d7e", "workflow": "wf_53fb2f6576fa4bc9a6d3c91a7e84de47", "workflow_name": "Newsletter Hero", "workflow_version": 4, "status": "pending", "progress": 0, "created_at": "2026-05-27T14:32:10+00:00"}Common rejections: 400 invalid_request (missing/invalid workflow), 404
workflow_not_found, 400 workflow_not_published, 400 validation_error (for example, a
required input was not provided — see
§2.1).
1.1 Idempotency (safe retries)
Section titled “1.1 Idempotency (safe retries)”Network timeouts and serverless retries can resend the same submit twice — and each submit would
otherwise start a new run and spend credits again. To make a submit safe to retry, send an
Idempotency-Key header with a fresh high-entropy value (a UUID is ideal) per intended run:
curl -s -X POST "$BASE_URL/api/v1/executions" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -H "Idempotency-Key: 5f1c2e9a-1b7d-4c83-9a2e-0e6f4b8d1234" -d '{ "workflow": "'"$WF"'", "inputs": { "block_title": { "value": "\"Spring in Bloom\"" } } }'Behaviour:
- First call runs normally and returns 202.
- Retry with the same key and the same request replays the original run — same execution
id, no new credits charged. The response is 202 with aIdempotency-Replayed: trueheader. - Reuse of the key with a different request body is rejected with 409
idempotency_conflict. A new run needs a new key. - A malformed key is rejected with 400
invalid_idempotency_key. The key must be 8–255 characters with no control characters; it does not need to be UUID-shaped. - If the idempotent submit committed a failed run because the workspace lacked credits, the response
is 402
insufficient_creditsand includes the failed run’sexecution_idfor correlation. Top up and retry with a new key.
The header is optional — omit it and the endpoint behaves exactly as before (no idempotency
record is written). On the MCP surface the equivalent idempotency_key is mandatory by design.
1.2 Runtime-sized fan-out limits
Section titled “1.2 Runtime-sized fan-out limits”When every iteration count is definition-owned, Madoo projects the complete Cartesian liability and
reserves it before the run starts. An uploaded CSV, JSON or XLSX used by the canonical
input/data → enumerate/data_rows chain can also be counted before execution: include the same
inputs/asset_path in POST /api/v1/workflows/{id}/estimate. If cardinality still depends on an
unknown runtime output (for example a collection produced inside the graph), callers should normally
provide one or both operational bounds:
"admission_limits": { "max_iterations_per_node": 100, "max_admitted_milli_credits": 50000}max_iterations_per_nodestops a node whose complete runtime plan exceeds that cardinality;max_admitted_milli_creditsstops the root when cumulative expected liability would exceed the bound (1000 milli = 1 credit).
These are work-admission bounds, not a promised debit ceiling. Actual credits follow actual provider consumption. Madoo reserves each newly-resolved plan before any paid iteration becomes runnable; if the available balance or a bound is exceeded, the execution ends as a terminal partial result and keeps outputs already produced.
The object is optional on the public REST and MCP execution surfaces. Without it, Madoo still admits each resolved paid plan against the organization’s available balance and never permits settlement to make the balance negative; the absolute 10,000-iterations-per-node technical ceiling also remains in force. The accepted residual is broader: an unbounded run may consume the organization’s full available balance before stopping. Agent-initiated runs apply a stricter product policy and require an explicit bound for deferred fan-out.
The limits are part of the idempotent request payload: reusing an Idempotency-Key with different
limits is an idempotency_conflict.
2. Inputs: value vs asset_path
Section titled “2. Inputs: value vs asset_path”inputs is an object keyed by the input’s name (from the default interface) or key (from a
custom interface). Each input value is an object with exactly one of two fields:
| Use | Field | For input types |
|---|---|---|
| Inline data | value |
text, number, boolean, json |
| Public media URL | value |
image, video, audio, document, model3d |
| A stored file | asset_path |
image, video, audio, document, model3d, data, csv, json |
"inputs": { "block_title": { "value": "\"Spring in Bloom\"" }, // inline text "max_variations": { "value": "3" }, // inline number "include_logo": { "value": "true" }, // inline boolean "remote_video": { "value": "\"https://media.example.com/demo.mp4\"" }, "product_image_0": { "asset_path": "uploads/ws-12/ab/hero.jpg" } // a file}Optional inputs may be omitted entirely (03-workflows §4).
Use asset_path for an uploaded/imported Madoo asset or a prior execution output
(05-assets.md). A public media URL can instead be passed as a JSON-encoded string in
value: Madoo downloads it once through the hardened SSRF/redirect/size/type policy, verifies its
format, and stores an execution-temporary copy before downstream nodes run. The source must be public
HTTPS and cannot require cookies, credentials, OAuth, or custom headers. Use asset import when the
file should remain permanently reusable in the workspace.
For input/bundle_manifest, the runtime input is the manifest document itself, serialized using
the normal inline value convention. Do not pass one of the contained files—or a .json file holding
the manifest—as this input’s asset_path. Build the document first with the Bundle Composer, then copy
the exact input key and encoding shape from execution_contract.primary.example. A runtime manifest
overrides the value saved in the node without modifying the workflow. See the canonical
Bundle manifest authoring guide.
2.1 Where input keys come from, and the fail-fast check
Section titled “2.1 Where input keys come from, and the fail-fast check”You never have to guess the keys. GET /api/v1/workflows/{id} returns an execution_contract whose
primary block is the one shape to copy: it lists the input keys with their types, a ready-to-copy
example, and the interface value to send (present only when the workflow’s recommended interface is a
custom one). The reliable recipe: copy execution_contract.primary.example verbatim — including its
interface field when present — and replace the values.
primary.interfacepresent (the author marked a custom interface as default): you MUST send thatinterfacevalue; the keys are that interface’s field keys. (Omittinginterfacehere would silently fall back to the raw auto-default below, with different keys.)primary.interfaceabsent/null: run through the raw auto-default interface — keys are the label-derived input names inexecution_contract.primary.inputs(equivalentlyinterface.inputs[].name), and the input node id also binds.- Other named interfaces are listed under
execution_contract.alternative_interfaces[](=interfaces[].fields[].key); select one by sending its id ininterface.
Fail-fast on a missing required input. If a required input is not provided (by key or by node id,
and with no default), the submit is rejected immediately with 400 validation_error — before the
run is created and credits are held. The message names the canonical key and includes a runnable
inputs example, e.g.:
Required input 'product_description' not provided. Pass values via the execution `inputs` argument(keys come from the workflow's execution_contract.primary — workflow detail in REST, get_workflow in MCP):{"inputs":{"product_description":{"value":"\"your value here\""}}}Scalars are JSON-encoded strings (text "\"...\"", number "5", boolean "true"); stored files use"asset_path". Public HTTPS media URLs may use a JSON-encoded string "value".This replaces the old failure mode where a missing required input ran until a node failed deep with an internal node id. Optional inputs are unaffected — omitting one still skips its branch (see § Optional inputs & skipping).
⚠️ The value field is a JSON-encoded string (double encoding)
Section titled “⚠️ The value field is a JSON-encoded string (double encoding)”This is the single most common source of integration bugs, so read carefully.
The value field is not the raw value — it is the value serialized to JSON, then carried as a
string. Concretely:
| You want to send | value must be (as JSON) |
Why |
|---|---|---|
the text Spring in Bloom |
"\"Spring in Bloom\"" |
a JSON string is quotes-wrapped |
the text hello |
"\"hello\"" |
same |
the number 42 |
"42" |
JSON number has no quotes |
the boolean true |
"true" |
JSON boolean has no quotes |
the object {"a":1} |
"{\"a\":1}" |
JSON object, escaped |
The reliable way to produce it in code is: run your value through a JSON serializer and put the
result in value. Then your request body gets serialized to JSON a second time when you send it —
hence “double encoding”.
// Correct: JSON.stringify the value, place the STRING in `value`.const v = (x: unknown) => ({ value: JSON.stringify(x) });
const body = { workflow: WF, inputs: { block_title: v("Spring in Bloom"), // → { value: "\"Spring in Bloom\"" } max_variations: v(3), // → { value: "3" } include_logo: v(true), // → { value: "true" } },};await fetch(`${BASE_URL}/api/v1/executions`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify(body), // ← the second serialization happens here});import json, requestsdef v(x): return {"value": json.dumps(x)} # json.dumps is the inner encoding
body = { "workflow": WF, "inputs": { "block_title": v("Spring in Bloom"), "max_variations": v(3), "include_logo": v(True), },}requests.post(f"{BASE_URL}/api/v1/executions", headers={"Authorization": f"Bearer {token}"}, json=body) # outer encodingWhy does it work this way? The engine accepts any JSON type (string, number, boolean, object) for an input, but the transport field is a string so the type is preserved unambiguously. The double-encoding is the price of that flexibility — once you wrap every value in
JSON.stringify/json.dumps, it is mechanical.
3. Execution status and the lifecycle
Section titled “3. Execution status and the lifecycle”status moves through these values. Four of them are terminal (the run is finished and will not
change):
status |
Terminal? | Meaning |
|---|---|---|
pending |
no | Accepted, queued, not started yet. |
running |
no | Executing. progress (0–100) and progress_message update as nodes complete. |
cancelling |
no | Cancellation requested; the cancel cascade is settling the run (see §7). |
completed |
✅ | Every node succeeded. All outputs are present. |
partial_success |
✅ | Finished, but some nodes failed. Some outputs may be present, some missing. |
failed |
✅ | The run failed. See error_code / error_message. |
cancelled |
✅ | Cancelled (by you, or by the system). |
Always treat completed, partial_success, failed, cancelled as “stop polling”.
Plan for
partial_success: in a multi-output workflow, one node failing should not make you discard the outputs that did succeed. Match the outputs you got and handle the missing ones gracefully.
The full execution object:
{ "id": "run_b4c8d3e2…", "workflow": "wf_53fb…", "workflow_name": "Newsletter Hero", "workflow_version": 4, "interface": null, // the custom interface id used, or null for default "status": "completed", "progress": 100, "progress_message": "Done", "credits_used": 8, // credits actually consumed (may be fractional) "error_code": null, "error_message": null, "created_at": "2026-05-27T14:32:10+00:00", "started_at": "2026-05-27T14:32:11+00:00", "completed_at": "2026-05-27T14:32:29+00:00", "outputs": [ /* see §6 */ ]}credits_used is in credits (Madoo’s cost unit; how many credits your plan includes is in
GET /api/v1/plan — see 08-reference.md). It is omitted until something has
been metered, grows while the run executes, and is final once the status is terminal. LLM nodes
meter fractional amounts, so the value is a decimal. For the pre-flight estimate use
POST /api/v1/workflows/{id}/estimate (10-authoring.md).
4. Polling for completion
Section titled “4. Polling for completion”There are no inbound webhooks to your server in v1. You learn that a run finished by polling:
GET {BASE_URL}/api/v1/executions/{id}until status is terminal. A poll interval of 3–5 seconds is a good default; tune it to your
expected run duration (a quick image is seconds, a video can be minutes).
async function waitForExecution(runId: string, { timeoutMs = 120_000, intervalMs = 4000 } = {}) { const terminal = new Set(["completed", "partial_success", "failed", "cancelled"]); const start = Date.now(); while (Date.now() - start < timeoutMs) { const res = await fetch(`${BASE_URL}/api/v1/executions/${runId}`, { headers: { Authorization: `Bearer ${token}` }, }); if (res.status === 429) { await sleep(intervalMs * 2); continue; } // backoff on rate limit const exec = await res.json(); if (terminal.has(exec.status)) return exec; await sleep(intervalMs); } throw new Error(`Timed out waiting for ${runId}`);}Use progress (0–100) and progress_message to drive a progress bar. Outputs appear
incrementally: the outputs array fills in as individual nodes finish, so a UI can render results
progressively even before the run is fully terminal.
5. Listing executions
Section titled “5. Listing executions”GET {BASE_URL}/api/v1/executions| Query param | Type | Description |
|---|---|---|
limit |
integer | 1–100, default 25. |
starting_after |
string | Pagination cursor (next_cursor from the previous page). |
status |
string | Filter: pending, running, completed, partial_success, failed, cancelled. |
workflow |
string | Filter to one workflow (wf_…). |
created_after / created_before |
ISO 8601 | Time-range filter. |
curl -s -H "Authorization: Bearer $TOKEN" \ "$BASE_URL/api/v1/executions?status=completed&limit=20"Returns the standard paginated envelope of execution objects.
6. Reading outputs
Section titled “6. Reading outputs”Use GET /api/v1/executions/{id}/result. This is the canonical, agent-ready way to read a run’s
outputs: it returns an indexed by_key map, reads small text/JSON bodies from storage for you,
and parses JSON even when it was stored inside a text output — so the single most common
integration bug (reading value and getting null for structured copy) disappears.
GET {BASE_URL}/api/v1/executions/{id}/resultIt returns HTTP 200 for any existing run — terminal or not (so you can also poll it; while the
run is in progress each expected output is present with state: "not_ready"). It returns 404 only
when the run does not exist, and 400 invalid_id for a malformed id.
🤖 If you are an AI agent, use this exact path: read
result.by_key["<key>"].valuefor text/JSON (already parsed — do not callJSON.parse) andresult.by_key["<key>"].urlfor binary (image/video/audio/3D/PDF). Copy the exact path from the workflow’sexecution_contract.outputs[].read_from(03-workflows §6) and use it verbatim — keys are not always dot-safe (e.g.hero-image), so always use bracket notationresult.by_key["hero-image"].url, neverresult.by_key.hero-image.read_fromalready emits bracket notation when the key needs it.
📦 Prefer not to hand-roll it? Copy the official, dependency-free TypeScript helper
examples/madoo-runtime.ts—runWorkflowAndGetResult(...)submits, polls, and returnsresult.by_keyfor you (readValue(result, key)/readUrl(result, key)).
6.1 The shape
Section titled “6.1 The shape”{ "execution_id": "run_b4c8d3e2…", "workflow_id": "wf_53fb…", "status": "completed", "is_terminal": true, "next_poll_after_ms": null, // a number while running; null when terminal "credits_used": 6, // the whole run's cost; present only once terminal (never a partial) "result": { "by_key": { "newsletter_copy": { "key": "newsletter_copy", "cardinality": "single", // "single" | "multiple" (iterated) "state": "present", // present | absent | missing | not_ready "value": { "headline": "…", "body": "…", "cta": "…" }, // ← parsed, read directly "url": null, "items": [ { /* per-occurrence detail; see §6.2 */ } ] }, "hero_image": { "key": "hero_image", "cardinality": "single", "state": "present", "value": null, "url": "https://cdn-testing.madoo.ai/…/hero.png", // ← binary: fetch the url "items": [ { /* … */ } ] } }, "items": [ /* flat list = every group's items concatenated, if you prefer iterating */ ] }, "diagnostics": { "message": "Execution finished. Read each output from result.by_key.<key>.value (text/JSON) or .url (binary).", "lookup_rule": "Use result.by_key.<key>; never match outputs[].name (it is suffixed for uniqueness).", "expected_output_keys": ["newsletter_copy", "hero_image"], "available_output_keys": ["newsletter_copy", "hero_image"], "warnings": [] }}Reading it is a direct lookup — no find(), no JSON.parse, no type === switch:
const res = await fetch(`${BASE_URL}/api/v1/executions/${runId}/result`, { headers: { Authorization: `Bearer ${token}` } }).then(r => r.json());
// Dot-safe keys can use dot access; ANY key works with bracket access — prefer bracket to be safe.const copy = res.result.by_key["newsletter_copy"].value; // { headline, body, cta } — already an objectconst hero = res.result.by_key["hero_image"].url; // "https://…/hero.png"// A non-dot-safe key (e.g. a custom interface output "hero-image") MUST use bracket notation:// const hero = res.result.by_key["hero-image"].url; // dot access (by_key.hero-image) would be undefined6.2 Per-output fields (by_key.<key> and each items[] entry)
Section titled “6.2 Per-output fields (by_key.<key> and each items[] entry)”| Field | Notes |
|---|---|
cardinality |
single or multiple. The group-level value/url shortcuts exist only when single; for multiple (iterated outputs sharing one key) read items[]. |
state |
present (produced), absent (an optional branch was skipped — no data, the run still succeeded), missing (expected but not produced, e.g. a failed run), or not_ready (still running). Read state before value/url. |
value |
Text/JSON content, already resolved and parsed. A JSON object when the body is JSON (including JSON stored in a text output); a plain string for prose; null for binary/absent. |
url |
Download URL for binary outputs (and for text bodies too large to inline). |
delivery (on items[]) |
inline · resolved_text (read from storage server-side) · asset (binary/oversized → use url) · absent · unavailable. |
parse (on items[]) |
{ status, format, strategy }. Branch on this, never assume: format is "json" or "text"; status is parsed / failed / not_attempted / too_large. |
raw_value (on items[]) |
The original text body, kept alongside the parsed value. |
thumbnail_url (on items[]) |
Small preview for image outputs. |
source (on items[]) |
{ name, logical_name, node_id } — provenance; name is the GUID-suffixed identifier you should not key on. |
6.3 value may be a string OR an object — branch on parse
Section titled “6.3 value may be a string OR an object — branch on parse”A text output can carry prose or JSON-in-text. /result parses it for you, but the type of
value depends on the content, so branch on parse.format / parse.status rather than assuming:
const item = res.result.by_key.newsletter_copy.items[0];if (item.parse.format === "json" && item.parse.status === "parsed") { const { headline, body, cta } = item.value; // object — use directly} else { const text = item.value; // string — prose, or JSON that failed to parse}If a text body looked like JSON but did not parse, value stays the string and parse.status is
failed — the response never throws; one output degrades, the rest are fine.
6.4 Raw outputs (advanced/debug)
Section titled “6.4 Raw outputs (advanced/debug)”GET {BASE_URL}/api/v1/executions/{id}/outputsThe original, flat, ungrouped output list. It does not read storage bodies or parse JSON: a
text/JSON output arrives either inline (value, a raw string you must JSON.parse yourself) or as a
url you must fetch — and you must dedupe/group by logical_name yourself. It returns HTTP 422
(not_completed) until the run is terminal. Prefer /result for new integrations; /outputs exists
for debugging and backward compatibility.
🔒 Privacy & lifetime of output URLs
Section titled “🔒 Privacy & lifetime of output URLs”How an output url behaves depends on the deployment’s storage configuration:
- Public storage (default): the
urlis a public, unsigned CDN URL — not guessable (the path contains an HMAC token), but anyone holding it can fetch the file with no authentication. It is not permanent: files are retained per the storage retention policy (≈60 days) and the URL stops working once the asset is purged. - Private storage: the
urlis a time-limited signed URL that expires (on the order of an hour).
In both cases, do not treat an output url as a forever link:
- For previews and short-lived use, just re-read
/resultto obtain a freshurlwhen you need one. - If the content must persist (or is sensitive), download it and re-host it through your own access-controlled / durable storage rather than handing the Madoo URL to end users.
- On the MCP surface,
get_execution_resultalso returns a durableasset_pathper output — the stable reference to use for chaining, independent of the signed/CDN URL’s lifetime.
Optional inputs & skipping
Section titled “Optional inputs & skipping”A workflow input can be optional. If you start a run without supplying an optional input, the engine does not fail the run — instead it skips the nodes that strictly require that value, and the absence propagates downstream: every node that depended on it is skipped too. This is a normal, successful outcome, not an error.
How it surfaces in the API:
- Output
state— an output produced by a skipped output node comes back from/resultwith"state": "absent"and novalue/url. The key still appears inby_key(so you can tell which output was skipped), it just carries no data. (On the raw/outputssurface the same thing shows as"presence": "absent".) - Run status — a run whose nodes all ended
completedorskippedfinishes ascompleted(notpartial_success; skipping is not failing).partial_successstill means a node genuinely failed (its expected key comes back withstate: "missing"). - Non-selected branches — the same mechanism backs conditional graphs: a branch that is not taken
is skipped, and its outputs are
absent.
So: read state before value/url. "absent" ⇒ that branch was intentionally not run; supply
the optional input if you want it to produce real data.
for (const [key, group] of Object.entries(res.result.by_key)) { if (group.state !== "present") continue; // absent / missing / not_ready — nothing to fetch // …handle group.value / group.url as usual}Execution trace: understand how the run executed
Section titled “Execution trace: understand how the run executed”GET /api/v1/executions/{run_id}/traceGET /api/v1/executions/{run_id}/trace/nodes/{node_id}/iterations?after=49&page_size=50The trace is a safe execution report. It returns each definition node_id, state, duration,
retry_count, attempt_count, credits, the provider/model combinations actually recorded, and an
iteration summary. attempt_count means attempts that started: it is zero before the first start and
otherwise retry_count + 1; it is not a forensic history of every attempt.
Completed video/subtitles nodes can also include media_processing: sanitized evidence for the
file-backed render (source_bytes, output_bytes, elapsed_ms, caption count and whether the MP4
has faststart). This is operational metadata, not media content; no storage URI, local path or
caption text is included. Speech-to-text routing remains in the separate processing object.
Iteration details are cursor-paged and include only positional input_indices, never input values.
The report intentionally excludes prompts, parameters, internal numeric IDs, worker IDs, provider
request URLs/tokens, raw provider costs and diagnostic payloads. Errors contain a stable code and a
safe public explanation. Use the trace_url carried by completion webhooks to reach the same report.
7. Cancelling an execution
Section titled “7. Cancelling an execution”POST {BASE_URL}/api/v1/executions/{id}/cancelRequests cancellation of a non-terminal run. Returns the execution object with status
cancelling. If the run is already terminal you get 422 (already_terminal).
curl -s -X POST -H "Authorization: Bearer $TOKEN" \ "$BASE_URL/api/v1/executions/$RUN_ID/cancel"Cancellation is cooperative and cascades through the engine: the run reports cancelling
while the cascade settles in-flight nodes, then lands on the terminal cancelled shortly
after — keep polling until you see it. Credits: any unused reserved credits are refunded,
but nodes that already completed keep their consumed credits — a cancel mid-run is not
necessarily free.
8. Exporting all outputs as a ZIP
Section titled “8. Exporting all outputs as a ZIP”For runs with many outputs, you can have Madoo bundle them into a downloadable ZIP. This is a small
asynchronous job of its own (request → poll status → download). Like every other execution
endpoint, the ZIP endpoints take the prefixed run_… ID.
# 1. Request the export (200 if already done, 202 if it's being built)curl -s -X POST -H "Authorization: Bearer $TOKEN" \ "$BASE_URL/api/v1/executions/$RUN_ID/zip"
# 2. Poll status until completedcurl -s -H "Authorization: Bearer $TOKEN" \ "$BASE_URL/api/v1/executions/$RUN_ID/zip/status"
# 3. Download a finished ZIP (302 redirect to the file). -L follows the redirect.curl -sL -H "Authorization: Bearer $TOKEN" \ "$BASE_URL/api/v1/executions/$RUN_ID/zip/$ZIP_ID/download" -o outputs.zipThe status response reports progress and, when complete, the available ZIP file(s):
{ "status": "completed", "progress_percent": 100, "total_outputs": 12, "total_size_bytes": 48210333, "completed_at": "2026-05-27T14:40:00+00:00", "zips": [ { "zip_id": 991, "part_index": 0, "total_parts": 1, "download_file_name": "newsletter-hero-outputs.zip", "size_bytes": 48210333, "file_count": 12 } ]}Large exports may be split into multiple parts (zips[]); download each zip_id.
Next: 05-assets.md — uploading the files you reference via asset_path.