Skip to content

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.


POST {BASE_URL}/api/v1/executions

Request 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).

Terminal window
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).


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:

Terminal window
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 a Idempotency-Replayed: true header.
  • 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_credits and includes the failed run’s execution_id for correlation. Top up and retry with a new key. Without an Idempotency-Key the answer is the same 402 insufficient_credits, without execution_id.
  • If a usage limit refused the run, the response is 402 credit_cap_reached (see §1.3), with the failed run’s execution_id when 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_paused and nothing is charged (see §1.4); retry after resets_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) ends failed with error_code AI_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.


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_node stops a credit-consuming node (ai/* and workflow/*) 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_credits stops the root when cumulative expected liability would exceed the bound (1000 milli = 1 credit). 0 admits only free steps: the run starts only when every paid step is known to cost nothing, and is refused with ADMISSION_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.

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 still credit_cap_reached and insufficient_balance says 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 failed with error_code CREDIT_CAP_REACHED and 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 (no resets_at, no Retry-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 failed with error_code AI_SPEND_PAUSED; the message says when AI resumes and the unused credits go back.
  • Do not retry in a loop: retry after resets_at with a new Idempotency-Key.

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.interface present (the author marked a custom interface as default): you MUST send that interface value; the keys are that interface’s field keys. (Omitting interface here would silently fall back to the raw auto-default below, with different keys.)
  • primary.interface absent/null: run through the raw auto-default interface — keys are the label-derived input names in execution_contract.primary.inputs (equivalently interface.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 in interface.

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:

  1. The request itself: a valid workflow_id, a well-formed body and Idempotency-Key (400 invalid_id / invalid_request / invalid_idempotency_key).
  2. The workflow exists in your workspace (404 workflow_not_found).
  3. An Idempotency-Key already used: the original run is replayed, or 409 idempotency_conflict if the body differs — even if the workflow has been unpublished since.
  4. The workflow is published (400 workflow_not_published).
  5. Every asset_path is a finalized asset of your workspace (400 invalid_asset_path).
  6. The inputs: required ones present and values valid (400 validation_error).
  7. Your admission_limits (400 validation_error naming the node or the liability over the limit), then plan limits, usage limits and credits (402 credit_cap_reached before 402 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, requests
def 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 encoding

Why 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.


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_LIMIT
error_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).


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.


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).
Terminal window
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)
Terminal window
curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/api/v1/executions?query=autumn%20campaign&limit=20"

MCP list_executions takes the same query.


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}/result

It 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>"].value for text/JSON (already parsed — do not call JSON.parse) and result.by_key["<key>"].url for binary (image/video/audio/3D/PDF). Copy the exact path from the workflow’s execution_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 notation result.by_key["hero-image"].url, never result.by_key.hero-image. read_from already 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 returns result.by_key for you (readValue(result, key) / readUrl(result, key)).

{
"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 object
const 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 undefined

Next 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.

GET {BASE_URL}/api/v1/executions/{id}/outputs

The 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:

Terminal window
# 1. Read the clips produced by the first run
curl -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 input
curl -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_path verbatim; never build it yourself and never derive it from the url.
  • 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 absent or missing output has no asset_path: check state first.

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=sheet

The 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.

How an output url behaves depends on the deployment’s storage configuration:

  • Public storage (default): the url is 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 url is 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 /result to obtain a fresh url when 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.

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 /result with "state": "absent" and no value / url. The key still appears in by_key (so you can tell which output was skipped), it just carries no data. (On the raw /outputs surface the same thing shows as "presence": "absent".)
  • Run status — a run whose nodes all ended completed or skipped finishes as completed (not partial_success; skipping is not failing). partial_success still means a node genuinely failed (its expected key comes back with state: "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}/trace
GET /api/v1/executions/{run_id}/trace/nodes/{node_id}/iterations?after=49&page_size=50

The 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.


POST {BASE_URL}/api/v1/executions/{id}/cancel

Requests cancellation of a non-terminal run. Returns the execution object with status cancelling. If the run is already terminal you get 422 (already_terminal).

Terminal window
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.


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.

Terminal window
# 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 completed
curl -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.zip

The 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.