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.

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 node whose complete runtime plan exceeds that cardinality;
  • max_admitted_milli_credits stops the root when cumulative expected liability would exceed the bound (1000 milli = 1 credit).

These are work-admission bounds, not a promised debit ceiling. Actual credits follow actual provider consumption. Madoo reserves each newly-resolved plan before any paid iteration becomes runnable; if the available balance or a bound is exceeded, the execution ends as a terminal partial result and keeps outputs already produced.

The object is optional on the public REST and MCP execution surfaces. Without it, Madoo still admits each resolved paid plan against the organization’s available balance and never permits settlement to make the balance negative; the absolute 10,000-iterations-per-node technical ceiling also remains in force. The accepted residual is broader: an unbounded run may consume the organization’s full available balance before stopping. Agent-initiated runs apply a stricter product policy and require an explicit bound for deferred fan-out.

The limits are part of the idempotent request payload: reusing an Idempotency-Key with different limits is an idempotency_conflict.


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.

Fail-fast on a missing required input. If a required input is not provided (by key or by node id, and with no default), the submit is rejected immediately with 400 validation_error — before the run is created and credits are held. The message names the canonical key and includes a runnable inputs example, e.g.:

Required input 'product_description' not provided. Pass values via the execution `inputs` argument
(keys come from the workflow's execution_contract.primary — workflow detail in REST, get_workflow in MCP):
{"inputs":{"product_description":{"value":"\"your value here\""}}}
Scalars are JSON-encoded strings (text "\"...\"", number "5", boolean "true"); stored files use
"asset_path". Public HTTPS media URLs may use a JSON-encoded string "value".

This replaces the old failure mode where a missing required input ran until a node failed deep with an internal node id. Optional inputs are unaffected — omitting one still skips its branch (see § Optional inputs & skipping).

⚠️ The value field is a JSON-encoded string (double encoding)

Section titled “⚠️ The value field is a JSON-encoded string (double encoding)”

This is the single most common source of integration bugs, so read carefully.

The value field is not the raw value — it is the value serialized to JSON, then carried as a string. Concretely:

You want to send value must be (as JSON) Why
the text Spring in Bloom "\"Spring in Bloom\"" a JSON string is quotes-wrapped
the text hello "\"hello\"" same
the number 42 "42" JSON number has no quotes
the boolean true "true" JSON boolean has no quotes
the object {"a":1} "{\"a\":1}" JSON object, escaped

The reliable way to produce it in code is: run your value through a JSON serializer and put the result in value. Then your request body gets serialized to JSON a second time when you send it — hence “double encoding”.

// Correct: JSON.stringify the value, place the STRING in `value`.
const v = (x: unknown) => ({ value: JSON.stringify(x) });
const body = {
workflow: WF,
inputs: {
block_title: v("Spring in Bloom"), // → { value: "\"Spring in Bloom\"" }
max_variations: v(3), // → { value: "3" }
include_logo: v(true), // → { value: "true" }
},
};
await fetch(`${BASE_URL}/api/v1/executions`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(body), // ← the second serialization happens here
});
import json, 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 failed. Some outputs may be present, some missing.
failed ✅ The run failed. See error_code / error_message.
cancelled ✅ Cancelled (by you, or by the system).

Always treat completed, partial_success, failed, cancelled as “stop polling”.

Plan for partial_success: in a multi-output workflow, one node failing should not make you discard the outputs that did succeed. Match the outputs you got and handle the missing ones gracefully.

The full execution object:

{
"id": "run_b4c8d3e2…",
"workflow": "wf_53fb…",
"workflow_name": "Newsletter Hero",
"workflow_version": 4,
"interface": null, // the custom interface id used, or null for default
"status": "completed",
"progress": 100,
"progress_message": "Done",
"credits_used": 8, // credits actually consumed (may be fractional)
"error_code": null,
"error_message": null,
"created_at": "2026-05-27T14:32:10+00:00",
"started_at": "2026-05-27T14:32:11+00:00",
"completed_at": "2026-05-27T14:32:29+00:00",
"outputs": [ /* see §6 */ ]
}

credits_used is in credits (Madoo’s cost unit; how many credits your plan includes is in GET /api/v1/plan — see 08-reference.md). It is omitted until something has been metered, grows while the run executes, and is final once the status is terminal. LLM nodes meter fractional amounts, so the value is a decimal. For the pre-flight estimate use POST /api/v1/workflows/{id}/estimate (10-authoring.md).


There are no inbound webhooks to your server in v1. You learn that a run finished by polling:

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

until status is terminal. A poll interval of 3–5 seconds is a good default; tune it to your expected run duration (a quick image is seconds, a video can be minutes).

async function waitForExecution(runId: string, { timeoutMs = 120_000, intervalMs = 4000 } = {}) {
const terminal = new Set(["completed", "partial_success", "failed", "cancelled"]);
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const res = await fetch(`${BASE_URL}/api/v1/executions/${runId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 429) { await sleep(intervalMs * 2); continue; } // backoff on rate limit
const exec = await res.json();
if (terminal.has(exec.status)) return exec;
await sleep(intervalMs);
}
throw new Error(`Timed out waiting for ${runId}`);
}

Use progress (0–100) and progress_message to drive a progress bar. Outputs appear incrementally: the outputs array fills in as individual nodes finish, so a UI can render results progressively even before the run is fully terminal.


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


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

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
cardinality single or multiple. The group-level value/url shortcuts exist only when single; for multiple (iterated outputs sharing one key) read items[].
state present (produced), absent (an optional branch was skipped — no data, the run still succeeded), missing (expected but not produced, e.g. a failed run), or not_ready (still running). Read state before value/url.
value Text/JSON content, already resolved and parsed. A JSON object when the body is JSON (including JSON stored in a text output); a plain string for prose; null for binary/absent.
url Download URL for binary outputs (and for text bodies too large to inline).
delivery (on items[]) inline · resolved_text (read from storage server-side) · asset (binary/oversized → use url) · absent · unavailable.
parse (on items[]) { status, format, strategy }. Branch on this, never assume: format is "json" or "text"; status is parsed / failed / not_attempted / too_large.
raw_value (on items[]) The original text body, kept alongside the parsed value.
thumbnail_url (on items[]) Small preview for image outputs.
source (on items[]) { name, logical_name, node_id } — provenance; name is the GUID-suffixed identifier you should not key on.

6.3 value may be a string OR an object — branch on parse

Section titled “6.3 value may be a string OR an object — branch on parse”

A text output can carry prose or JSON-in-text. /result parses it for you, but the type of value depends on the content, so branch on parse.format / parse.status rather than assuming:

const item = res.result.by_key.newsletter_copy.items[0];
if (item.parse.format === "json" && item.parse.status === "parsed") {
const { headline, body, cta } = item.value; // object — use directly
} else {
const text = item.value; // string — prose, or JSON that failed to parse
}

If a text body looked like JSON but did not parse, value stays the string and parse.status is failed — the response never throws; one output degrades, the rest are fine.

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. Prefer /result for new integrations; /outputs exists for debugging and backward compatibility.

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

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.
  • On the MCP surface, get_execution_result also returns a durable asset_path per output — the stable reference to use for chaining, independent of the signed/CDN URL’s lifetime.

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.

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


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.