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. Without anIdempotency-Keythe answer is the same 402insufficient_credits, withoutexecution_id. - If a usage limit refused the run, the response is 402
credit_cap_reached(see §1.3), with the failed run’sexecution_idwhen a key was sent. Retry after the reset with a new key. - If AI generation is stopped — an AI safety limit was reached, or Madoo has temporarily paused AI
generation (for the platform, your plan, your organization or your workspace) — a run with AI steps is refused
with 503
ai_spend_pausedand nothing is charged (see §1.4); retry afterresets_at, or later, with a new key. A run already in progress whose AI step meets a pause (including a pause of a single AI provider) endsfailedwitherror_codeAI_SPEND_PAUSED; its unused credits go back.
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 credit-consuming node (ai/*andworkflow/*) whose complete runtime plan exceeds that cardinality — checked at submit when the count is already known, otherwise when the plan resolves at run time. Free deterministic nodes (text, data, image utilities, …) are not limited by it: a zero-credit fan-out of 3 items runs in full under a limit of 2;max_admitted_milli_creditsstops the root when cumulative expected liability would exceed the bound (1000 milli = 1 credit).0admits only free steps: the run starts only when every paid step is known to cost nothing, and is refused withADMISSION_LIMIT_EXCEEDED, naming the steps that can spend, otherwise — the contract for an app that runs layout-only workflows and must never spend.
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.
1.3 Usage limits (credit_cap_reached)
Section titled “1.3 Usage limits (credit_cap_reached)”An organization can cap the credits it consumes per UTC calendar day and per UTC calendar month, for the whole organization and for each workspace (Settings → Billing). Every cap that applies counts: the first one reached blocks. “Used” is the credits already consumed in the period plus what the runs still in progress have reserved, so several runs submitted at the same time cannot all pass a limit that has room for one.
A submit that would go above a limit is refused with 402 credit_cap_reached:
{ "type": "https://docs.madoo.ai/public-api/errors/credit-cap-reached", "title": "Payment Required", "status": 402, "code": "credit_cap_reached", "detail": "Daily usage limit of this workspace reached: 500 of 500 credits used. New executions resume at 2026-10-21 00:00 UTC, or an organization owner or admin can raise the limit.", "execution_id": "run_…", // only with an Idempotency-Key: the run committed as failed "credit_cap": { "scope": "workspace", // "organization" or "workspace" "period": "day", // "day" (UTC calendar day) or "month" (UTC calendar month) "cap_credits": 500, "used_credits": 500, // consumed + reserved by runs in progress "requested_credits": 2.5, // what this run needed up front "resets_at_utc": "2026-10-21T00:00:00+00:00", "insufficient_balance": true, // only when the balance was short too "available_credits": 1 // only with insufficient_balance }}- Adding credits does not help. New executions start again at
resets_at_utc, or as soon as an organization owner or admin raises the limit. When the balance is short too, the code is stillcredit_cap_reachedandinsufficient_balancesays so. - A run whose price is only known while it runs (a fan-out sized by a runtime output, a runtime-priced step) needs some room below every limit to start, even when nothing is reserved up front.
- A run already in progress completes the work it has reserved. When it needs more (dataset rows, a
sub-workflow, a repeat attempt, a runtime-priced step) and a limit has no room, the whole run ends
failedwitherror_codeCREDIT_CAP_REACHEDand the same message: outputs already produced are kept and the credits not used are returned. A batch stops the same way: the item that met the limit fails and the items not started yet are cancelled. - The AI assistant and Madoo AI are under the same limits: a request is refused when no room is left. A request already answered is always charged, so a limit can be exceeded by about one request.
1.4 AI safety limits and pauses (ai_spend_paused)
Section titled “1.4 AI safety limits and pauses (ai_spend_paused)”Madoo limits how much AI an organization (and, for some, a workspace) can use in a UTC day and a UTC month, to protect the account from unexpected spending — see AI safety limits. These are not the usage limits of §1.3: they are set by Madoo, cover AI generation only, and show to the customer as percentages, never amounts. A paid plan’s limit grows with the credits the organization buys.
When one is reached, a run with AI steps is refused at submission with 503 and an ai_spend member, and the
response carries Retry-After (seconds until AI resumes):
{ "type": "https://docs.madoo.ai/public-api/errors/ai-spend-paused", "title": "AI Generation Paused", "status": 503, "detail": "Your AI safety limit for today has been reached. AI generation resumes on 2026-10-26 00:00 UTC, or sooner if the limit is raised. Steps without AI keep working.", "code": "ai_spend_paused", "instance": "/api/v1/executions", "request_id": "0HM…", "ai_spend": { "reason": "safety_limit", // safety_limit | paused "resets_at": "2026-10-26T00:00:00+00:00" // when AI resumes (safety_limit only) }}reason: "paused"means Madoo paused AI generation for a moment (noresets_at, noRetry-After): retry later.- Steps without AI keep working, and a run without AI steps is not refused. Nothing is charged for a refused run.
- A run already in progress whose AI step meets the limit ends
failedwitherror_codeAI_SPEND_PAUSED; the message says when AI resumes and the unused credits go back. - Do not retry in a loop: retry after
resets_atwith a newIdempotency-Key.
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.
The order of the checks. A submit is checked in this order, and the first failing check is the error you
get — so a draft with wrong inputs answers workflow_not_published, not validation_error:
- The request itself: a valid
workflow_id, a well-formed body andIdempotency-Key(400 invalid_id/invalid_request/invalid_idempotency_key). - The workflow exists in your workspace (
404 workflow_not_found). - An
Idempotency-Keyalready used: the original run is replayed, or409 idempotency_conflictif the body differs — even if the workflow has been unpublished since. - The workflow is published (
400 workflow_not_published). - Every
asset_pathis a finalized asset of your workspace (400 invalid_asset_path). - The inputs: required ones present and values valid (
400 validation_error). - Your
admission_limits(400 validation_errornaming the node or the liability over the limit), then plan limits, usage limits and credits (402 credit_cap_reachedbefore402 insufficient_credits, see §1.1–1.3).
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. The same 400 validation_error refuses, before the run, a key the contract does not
accept, a value that is not JSON-encoded, and a number or boolean input sent as text: "\"2\"" for a number,
"\"true\"" for a boolean. Number and boolean inputs never convert text, so the message names the expected
type (Input 'gift_wraps' expects a number (for example "5"), not text). 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 or some of their iterations failed. Some outputs may be present, some missing. error_message says why and error_code carries the root cause (see below). |
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.
A run that does not complete always says why. error_message names each cause — up to three — with the step’s
label, its error code and the underlying message: a step that failed, the iterations of a step that failed (how many
of how many), a sub-workflow step that completed partially, with that sub-workflow’s own reason. error_code is the
first cause’s own code, so a quota exceeded inside a sub-workflow reaches the caller as RATE_LIMIT, not only as a
generic failure:
error_code: RATE_LIMITerror_message: Step “Natural Speech Localization Engine” failed (CHILD_PARTIAL_SUCCESS): Child workflow completed with partial success: 2 of 4 iterations of step “Hear Each Speaker's Voice” failed (RATE_LIMIT): Provider returned 429 (TooManyRequests): …A step whose AI provider is temporarily unavailable on Madoo’s side fails with error_code
PROVIDER_BALANCE_EXHAUSTED and the message “The AI provider is temporarily unavailable…”. It is not retried and
costs nothing; Madoo is alerted at once. Steps without AI keep working; run it again later.
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”You learn that a run finished either from an outbound webhook — Madoo calls your HTTPS endpoint when a run reaches a terminal state — or 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. |
query |
string | Search the whole history, page by page (see below). |
curl -s -H "Authorization: Bearer $TOKEN" \ "$BASE_URL/api/v1/executions?status=completed&limit=20"Returns the standard paginated envelope of execution objects. The list shows the executions that were started directly; the ones a workflow step starts for itself (a sub-workflow, the attempts of Repeat Until Accepted) are reached from their parent’s trace.
Searching. The server searches query against every execution of the workspace and paginates the result like
any other list, so an execution is found however old it is:
query holds |
Returns |
|---|---|
an execution ID, run_… |
that execution — a child execution too, such as an attempt of Repeat Until Accepted |
a workflow ID, wf_… |
the executions of that workflow; together with workflow, each narrows the other |
| any other text | the executions of the workflows whose name contains it, ignoring case; the text is taken literally (% and _ are not wildcards) |
curl -s -H "Authorization: Bearer $TOKEN" \ "$BASE_URL/api/v1/executions?query=autumn%20campaign&limit=20"MCP list_executions takes the same query.
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 undefinedNext to result and diagnostics, the body carries which interface keyed the outputs:
| Field | Notes |
|---|---|
requested_interface_id |
The interface sent at submit; omitted for a run on the auto default. |
result_interface_id |
The interface actually used to key by_key. |
interface_resolution |
auto_default, custom, or fallback_auto_default (the requested interface no longer resolves — the run is keyed by the auto default and diagnostics.warnings says so). |
diagnostics.filtered_outputs |
Outputs the run produced that the selected custom interface does not expose, each { logical_name, node_id, type }. They are not in by_key. |
6.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 |
|---|---|
presence |
present or absent — whether the node delivered a value; state adds missing and not_ready on top. |
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; strategy is declared_json (a json output), opportunistic_text_json (JSON found inside a text output) or none. |
occurrence_id, occurrence_ordinal (on items[]) |
A stable id for this item and its position among the items of the same key. |
iteration_index (on items[]) |
The iteration that produced the item; null outside an iteration. |
key, interface_key, logical_name, type (on items[]) |
The group key, the key in the selected interface, the node’s output name and the output type. |
content_type, size_bytes (on items[]) |
MIME type and size of a stored item. |
raw_value (on items[]) |
The original text body, kept alongside the parsed value. |
thumbnail_url (on items[]) |
Small preview for image outputs. |
asset_path (on items[]) |
The durable reference to a file output in your workspace storage. Pass it verbatim as an input’s asset_path to feed this output into another run — see §6.5. Present for file outputs (image, video, audio, document, 3D, and text/JSON stored as a file); omitted for inline values and absent outputs. It is not a link: use url to download. |
source (on items[]) |
{ name, logical_name, node_id } — provenance; name is the raw storage name {logical_name}_{node_id} ({logical_name}_{iteration_index}_{node_id} for an iterated output) — you should not key on it. |
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. Each file output carries the same asset_path as in
/result. Prefer /result for new integrations; /outputs exists for debugging and backward
compatibility.
6.5 Chaining runs: reuse an output as an input
Section titled “6.5 Chaining runs: reuse an output as an input”A file produced by one run can be the input of the next — clips from an animation run joined by an
editing run, images reviewed by a person before a layout run, a voice track reused in several videos —
without downloading it and uploading it again. Every file output carries an asset_path, in
/result (items[].asset_path) and in /outputs, the same value the MCP tools return. Pass it as the
asset_path of an input of the next execution:
# 1. Read the clips produced by the first runcurl -s "$BASE_URL/api/v1/executions/$RUN_1/result" -H "Authorization: Bearer $TOKEN" | jq -r '.result.by_key.clips.items[].asset_path'# exec/…/n/30962/a/7ab2…/clip-0.mp4# exec/…/n/30962/a/9c41…/clip-1.mp4
# 2. Start the second run with one of them as a video inputcurl -s -X POST "$BASE_URL/api/v1/executions" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" -d '{ "workflow": "'"$WF_EDIT"'", "inputs": { "video": { "asset_path": "exec/…/n/30962/a/7ab2…/clip-0.mp4" } } }'To hand a whole list of files to the next run (all the clips of an iterated output), write them
into a JSON array — one object per file, its asset_path in a field — upload that JSON file as an
asset, and pass it to a JSON Input (input/json) whose field is typed as a file (outputType
video, image, audio…): each element then arrives in the workflow as that file.
The rules:
- Copy
asset_pathverbatim; never build it yourself and never derive it from theurl. - It works only inside the same workspace that produced it, and only through the API (it is not a link anyone can open).
- It lives as long as the output does: files follow the storage retention policy (below). For a file you will reuse for months, copy it into your assets with an asset import.
- An
absentormissingoutput has noasset_path: checkstatefirst.
6.6 Look at an output: previews
Section titled “6.6 Look at an output: previews”To check what a run produced — every page of a brochure, the slides of a carousel, the variants of an image — ask for a picture of the output instead of downloading each file:
GET {BASE_URL}/api/v1/executions/{id}/outputs/{key}/preview?mode=sheetThe answer is always one image/jpeg. key is the output key of /result (by_key), or an output name.
| Output | What the items are | Label on a sheet |
|---|---|---|
| an image | the image | — |
| an iterated image output (one image per row) | each image | #1, #2… (the row) |
| a PDF | each page | p. 1, p. 2… |
a design pages manifest (pages_manifest of Render Design Template: a carousel) |
each slide | 1/5, 2/5… |
| a video | a sheet of 12 frames spread over it, from the first to the last, each stamped with its time; then the waveform of its sound, if it has one | frames, sound (#2 frames for the second row of an iterated output) |
| an audio | its waveform, labelled with its duration and loudness | sound |
Video and audio are prepared by the worker on the first request — frames and waveforms need FFmpeg, which never
runs in the API. The first call answers 202 at once with Retry-After and
{"code":"preview_pending","detail":…,"retry_after_seconds":10}; call again after that many seconds and the pictures
are there (usually within seconds; a few more for a long video). Nothing waits on either side. The pictures are kept
next to the file and deleted with the run; they count towards workspace storage like image thumbnails. On MCP the
same preview also returns the media facts of each file: duration, container, codecs, size, frame rate, pixel
format, whether it has sound, integrated loudness (LUFS), true peak, the silence at the start and at the end, and every
silence of half a second or more inside it — what to check before delivering a video (a teaser with no sound, a
yuv444p file that some browsers will not play, a voice that starts late).
Look closely at a moment with from and to: the twelve frames are then spread over that stretch — over 1.5
seconds they are about 130 ms apart, enough to see how a transition is made or when a title disappears — and the
waveform, loudness and silences are those of the stretch alone, measured on a lossless cut (silence times stay in the
file’s time). Each stretch is prepared once, like the whole file, and kept until the run is deleted. A stretch that
starts after the end of the file is answered 400 invalid_argument with the file’s duration in the message. A good
review: the whole file first, then the moments that look wrong.
| Parameter | Values | Default |
|---|---|---|
mode |
auto: one item as an image, several on a contact sheet; image: one item; sheet: the items on one contact sheet, a label under each |
auto |
items |
the items to show, 1-based numbers and ranges: 3, 1-4,7 |
all (the first, for image) |
max_dimension |
the long side of the picture, 256–2048 px | 1024; 1600 for a sheet |
from, to |
video and audio only: the stretch to picture, in seconds from the start of the file (from=4.5&to=6); rounded to a tenth of a second; to omitted means the end |
the whole file |
A sheet holds up to 50 items. The response headers say what it shows: X-Madoo-Preview-Mode,
X-Madoo-Preview-Items (the items shown, e.g. 1,2,3) and X-Madoo-Preview-Total-Items. Previews are read-only and
cost no credits; they need the executions:read scope.
| Status | code |
When |
|---|---|---|
| 400 | invalid_id, invalid_argument |
a malformed run id, mode, items out of range, max_dimension out of range |
| 404 | not_found, output_not_found |
no such run in the workspace; no output with that key (the message lists the keys) |
| 422 | not_completed |
the run is not terminal yet |
| 422 | output_absent |
the output was skipped (an optional branch) |
| 422 | not_previewable |
text or JSON — read it from /result |
| 202 | preview_pending |
a video or audio preview is being prepared; call again after Retry-After seconds |
| 500 | preview_failed |
the file could not be previewed (the message gives the reason, e.g. not a readable video); asked again after 10 minutes |
| 422 | output_too_large |
over 60 MB for an image, 150 MB for a PDF; download it |
| 503 | busy |
the page renderer is busy; retry after Retry-After seconds |
On MCP the same preview is the tool preview_output (execution_id, key): the pictures come back as image
blocks a multimodal agent sees, with mode pages too (each item as its own image, up to max_images). A good
review: the sheet first, then the items that look wrong one by one.
🔒 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). - When the organization’s subscription ends, runs and their outputs are no longer purged by the retention policy: they stay readable in the Madoo web app until the organization’s data is deleted, 90 days after the end (the owners are warned about a month and 15 days before), unless a new subscription starts first. See When a subscription ends.
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.
- To reuse an output inside Madoo, use its
asset_path(here and on MCP), the stable reference for chaining, independent of the signed/CDN URL’s lifetime (§6.5).
When you download an output from code, send a User-Agent header that names your application (for
example User-Agent: borghi-kit/1.0). The CDN in front of the output files may refuse requests that
carry a bare library default — Python’s urllib, for one — with HTTP 403; curl, browsers and
most HTTP clients send an agent of their own and are not affected.
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.
Two kinds of absence behave differently downstream:
| Absence | Where it comes from | A node that receives it on a required port | …on an optional or dynamic port |
|---|---|---|---|
| Not provided | an optional input left empty, an output a node produces only on request | skips | runs without it (e.g. ai/image_transform with only the first image) |
| Rejected | a decision cut the branch: utility/filter that rejects, utility/quality_gate in route mode that rejects, utility/select_by_key with no match, utility/extract with When Missing absent and the field missing, utility/json_schema_validate in route mode with an invalid document, utility/json_parse in route mode with a text that is not JSON or not of its schema, ai/decide with On Uncertain skip_branch and an answer below its threshold (DECISION_UNCERTAIN) |
skips | skips too |
A node skipped by a rejected branch records BRANCH_REJECTED, so every node after it skips as well: a
rejected branch never runs — nor spends credits — anywhere downstream. A few inputs are made to receive a
rejected branch and keep their node running; the catalog marks them with accepts_rejected on the port or
accepts_rejected_inputs on the node (every input, dynamic ones included): the background of
audio/duck (the voice passes alone), utility/select_by_key, utility/boolean_combine,
utility/quality_gate and utility/dubbing_manifest. Aggregators (aggregate/*) collect branches: a
rejected item is a hole in the collection.
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.
An item of a sub-workflow step inside an iteration — it runs once per item — carries child_execution_id and
child_trace_url, the run that item called, exactly like the node of a scalar sub-workflow.
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.
Code casing. The trace writes every error code in lower case (row_conversion_failed,
code_not_scannable, bundle_manifest_invalid), while the guides and the step error of the run name the same
codes in upper case (ROW_CONVERSION_FAILED). They are the same code: compare them case-insensitively.
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.