This is the full developer documentation for Madoo
# Madoo documentation
> How Madoo works, how to build workflows and document templates with it, and how to integrate it through the Public API and MCP.
Madoo is a workflow engine for AI media generation: images, video, audio, 3D and documents (PDF). You describe the work as a graph of nodes — inputs, processing steps, outputs — publish it, and run it as many times as you need, one item or thousands, with the same result every time.
This documentation is written for AI agents first and for people too. Every page opens with the situation in which it is worth reading; read the page that fits your task instead of reading everything.
## Where to go
[Section titled “Where to go”](#where-to-go)
| Your task | Read |
| -------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| Understand what Madoo is for and which way of working with it fits you | [What Madoo is](/madoo/what-is-madoo/) |
| Build or change a workflow, or explain a validation error | [How workflows are built](/workflows/how-workflows-work/) |
| Process a list of items, or make one file from many results | [Iteration](/workflows/iteration/) |
| Create, change or fill a document template (brochures, sheets, price lists, certificates, posters) | [Document templates](/templates/) |
| Integrate Madoo into a product or a backend, or connect an AI assistant | [Public API and MCP](/public-api/) |
| Connect Claude, ChatGPT, Cursor or another assistant to Madoo | [MCP server](/public-api/mcp-server/) |
| Author a workflow definition through the API | [Authoring workflows](/public-api/authoring/) |
| Author or render a document template | [Design templates](/public-api/design-templates/) |
More guides — template techniques and the audio, video and image techniques — are being written and will appear here.
## For AI agents
[Section titled “For AI agents”](#for-ai-agents)
Every page is also available as plain markdown: add `.md` to its address (this page is [`/index.md`](/index.md)). [`/llms.txt`](/llms.txt) is the entry point for agents: it points to [`/llms-full.txt`](/llms-full.txt), the whole documentation in one file, and to a shorter version of it.
# What Madoo is
> Madoo is a workflow engine for producing and transforming content — images, video, audio, 3D and documents — with and without AI, repeatably and at any scale, built to be the content engine behind other products.
Madoo is a **workflow engine for producing and transforming content**: images, video, audio, 3D models, text and documents. You describe a piece of production work once — as a workflow — and Madoo runs it for you: on one item or on thousands, today or in a year, with the same steps, the same rules and the same kind of result every time.
Madoo is not a single AI tool. It **orchestrates** many of them — image, video, audio, speech and language models from several providers — together with a large set of **deterministic** processing steps that involve no AI at all: resizing and cropping, color and text overlays, video trimming and merging, audio mixing and loudness, subtitles, PDF documents filled from data, spreadsheets, validation and routing. Most real production work needs both.
## Why Madoo exists
[Section titled “Why Madoo exists”](#why-madoo-exists)
Producing content with AI looks easy for one image and becomes hard as soon as it is real work. Madoo was built around five problems that appear every time:
* **Complexity.** Real content is rarely one model call. A product sheet needs a cut-out photo, a generated setting, copy in two languages, a price from a catalog and a PDF layout. A localized video needs a transcript, a translation, timed captions, a synthetic voice that fits the timing, and a final mix. Madoo lets you express that chain as one workflow, with every step visible and inspectable.
* **Repeatability.** A good result obtained by hand in a chat cannot be replayed on the next product. A workflow captures the process — models, prompts, parameters, rules — so the same process runs again on new inputs. A published workflow is versioned and does not change under you.
* **Elasticity.** The same workflow runs on one item or on a whole catalog. Lists fan out into parallel work and are collected back into one result (a CSV, a JSON, a multi-page PDF, a merged video) — see [Iteration](/workflows/iteration/).
* **Scalability.** Executions run on a pool of workers, in parallel, with retries, cost limits and credit accounting per step. Adding volume does not add people.
* **Integrability.** Content is needed inside other systems — a shop, a CMS, a mobile app, a back office. Every workflow can be run through an API, from an AI assistant, inside an embedded widget or as an App, so Madoo can be the content engine behind someone else’s product.
## The workflow is the unit of work
[Section titled “The workflow is the unit of work”](#the-workflow-is-the-unit-of-work)
Everything in Madoo revolves around the **workflow**: a graph of **nodes** connected by typed **ports**.
* **Input nodes** receive what changes from one run to the next: a photo, a video, a text, a number, a CSV or JSON dataset, a list of values.
* **Processing nodes** do the work. Some call AI models (generate or edit an image, write copy, transcribe speech, synthesize a voice, animate a picture); many are deterministic (resize, crop, trim, merge, mix, render a document template, validate JSON, filter, pick a branch).
* **Output nodes** name what the run returns: an image, a video, an audio file, a text, a JSON object, a CSV, a PDF, a 3D model.
A workflow is authored as a **draft**, checked by validation, **published** as an immutable version, and then **executed**. Each execution records what every node did, what it produced and what it cost. The catalog has around two hundred node types; how they combine is explained in [How workflows are built](/workflows/how-workflows-work/).
Because the same workflow can be run with different inputs, it behaves like a function: the inputs are its parameters, the outputs are its result. That is what makes it reusable by people, by programs and by AI agents alike.
## AI and deterministic processing, together
[Section titled “AI and deterministic processing, together”](#ai-and-deterministic-processing-together)
Madoo treats AI and non-AI steps as equals in the same graph, and that is deliberate:
* **AI where judgment or creation is needed** — generating an image, rewriting copy for a market, choosing the best moments of a video, translating captions, producing a natural voice.
* **Deterministic steps where exactness is needed** — the price printed on a flyer must be the one in the catalog, a caption must end when the speech ends, a PDF must follow the brand template, a video must be exactly 1080 × 1920. These steps give the same output for the same input, cost little or nothing, and make AI output safe to use in production.
A good workflow often lets AI propose and deterministic steps decide: an AI node writes a JSON answer, a schema validation checks it, a predicate routes it, and a template renders it with facts that never passed through a model. Credits are spent only where AI or heavy processing is used, and every run can be estimated before it starts.
## Many ways to author and run the same workflows
[Section titled “Many ways to author and run the same workflows”](#many-ways-to-author-and-run-the-same-workflows)
A workflow is the same object whichever way it is built or run. Madoo exposes it on several surfaces, and all of them matter because they serve different people:
| Surface | Who uses it | What it is for |
| --------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Visual editor** | Designers, creative technologists, developers | Build and inspect workflows on a canvas: nodes, connections, parameters, test runs, execution history. |
| **Document template editor** | Designers | Design the page layouts (brochures, sheets, certificates, posters) that workflows fill with data and render as PDF or images. |
| **Madoo AI** — the built-in agent | Non-technical users | Describe the goal in plain language; the agent finds or builds the workflow, explains the cost, runs it and can turn it into an App. |
| **Apps** | Anyone | A simple interface over one or more workflows: fill a form, upload a file, get the result — without opening the editor. |
| **Public API (REST)** | Developers | Run, author, validate and publish workflows and templates from any backend. See [Public API and MCP](/public-api/). |
| **MCP server** | AI assistants and coding agents | Connect Claude, ChatGPT, Codex, Cursor and other assistants: they can discover nodes, build workflows and templates, run them and read the results. See [MCP server](/public-api/mcp-server/). |
| **Embeds, batches, webhooks** | Integrators | Put a workflow or the editor inside another product, run thousands of items as a batch, get notified when a run ends. |
These surfaces are kept at parity: a workflow built by an agent over MCP opens in the visual editor, a workflow drawn in the editor can be run over the API, and the same rules validate all of them.
## Madoo as the content engine of an application
[Section titled “Madoo as the content engine of an application”](#madoo-as-the-content-engine-of-an-application)
A large share of the value of Madoo is in what others build on top of it. Two ways of building software make this especially direct:
* **Coding agents** (Claude Code, Codex and similar) connect to Madoo over MCP, build and test the workflows an application needs, and write the application code that calls them through the API.
* **Vibe-coding platforms** (Lovable and similar) connect to Madoo as a backend for content: the app provides the user experience, Madoo provides the production pipeline — image generation, video localization, document rendering — with credits, limits and results already handled.
In both cases the workflow is the contract between the application and Madoo: the application sends inputs and reads outputs; how the content is produced can evolve inside the workflow without changing the application.
For people who do not write code, **Madoo AI** plays the same role: it lets a non-technical user set up a content production pipeline — including AI steps — by describing it, and hand it to colleagues as an App.
## What Madoo is used for
[Section titled “What Madoo is used for”](#what-madoo-is-used-for)
Madoo is multi-domain. A few examples of work that fits a workflow:
* **E-commerce and retail** — product photos cut out and placed in settings, descriptions in several languages, product sheets and catalogs as PDF.
* **Marketing and advertising** — campaign visuals in every format, localized copy, flyers and posters from a brief and a product list.
* **Publishing and education** — illustrated summaries, certificates for every participant of a course, documents assembled from data.
* **Real estate and hospitality** — listings built from a photo shoot and a property sheet, brochures from room data and photos.
* **Video and audio** — subtitles and translated captions, dubbing with natural timing, voice clean-up and audio finishing, highlights cut from long recordings, screen recordings turned into polished videos.
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
* [How workflows are built](/workflows/how-workflows-work/) — nodes, ports, the lifecycle of a workflow and what happens when it runs.
* [Iteration](/workflows/iteration/) — how one workflow processes a list of items, and how the results are collected back.
* [Public API and MCP](/public-api/) — integrating Madoo into a product or connecting an assistant.
# Madoo Public API — Developer Guide (v1)
Welcome. This guide is everything you need to integrate **Madoo** into your own product or backend. It is written for developers who have never seen Madoo before: by the end of this page you will understand *what* Madoo does, *how* an integration is shaped, and *where* to go next for the details.
You do not need to read our source code, and you do not need to understand how the engine works internally. You only need to understand a handful of concepts — and this page introduces all of them.
***
## 1. What is Madoo?
[Section titled “1. What is Madoo?”](#1-what-is-madoo)
Madoo is a **workflow engine for AI-powered media generation** — images, video, audio, 3D, text and print-ready documents — for marketing, advertising, publishing, e-commerce and more. A *workflow* is a pipeline that someone has designed in Madoo’s visual editor: it takes some inputs (a product photo, a brand description, a title…), runs them through a series of AI steps, and produces some outputs (a hero image, a piece of marketing copy, a video…).
The Public API lets your application do, programmatically, what a person would otherwise do by hand in the Madoo dashboard:
1. **Discover** the workflows available to you and learn exactly what each one expects.
2. **Run** a workflow by submitting inputs.
3. **Track** the run as it progresses.
4. **Retrieve** the generated outputs.
A useful mental model: **a workflow is like a function, and the Public API is how you call that function over HTTP.** The function’s signature — its parameters and its return values — is what we call the workflow’s *interface* (see §4). Your job as an integrator is to read that interface, fill in the inputs, call the function, and collect the results.
> **One workflow, run thousands of times.** Workflows are designed once (by you or by a Madoo author) and then executed repeatedly with different inputs. That is the whole point: a “newsletter hero image” workflow is built once and then run for every product in a catalog.
***
## 2. The core mental model: Organization → Workspace → API key
[Section titled “2. The core mental model: Organization → Workspace → API key”](#2-the-core-mental-model-organization--workspace--api-key)
Everything in Madoo is scoped by a two-level tenancy model. You must understand it because it determines what your API calls can see and do.
```plaintext
Organization (your company / account — billing lives here)
└── Workspace (a project / brand / environment inside the org)
└── API key (credentials your app uses — scoped to a workspace)
└── Workflows, Executions, Assets… (everything you touch via the API)
```
* An **Organization** is the top-level account. Your plan, credits, and billing belong to the organization.
* A **Workspace** is a subdivision of the organization — think of it as a project or a brand. All resources (workflows, executions, uploaded files) live inside a workspace.
* An **API key** is the credential your application uses. **Every API key is bound to a single workspace.** When you authenticate, the resulting access token already “knows” which organization and workspace it belongs to — so your calls automatically operate inside that workspace, and cannot see another workspace’s data.
This is why **workspace context is mandatory** for almost every endpoint: the API needs to know *which* workspace you are acting in, and that information comes from your API key. (The only exception is the token endpoint itself, which is how you authenticate in the first place.)
> There are two flavours of API key — **workspace-level** (bound to one specific workspace) and **organization-level** (which resolves to the organization’s *default* workspace). For most integrations a workspace-level key is the right choice. See **[01-authentication.md](/public-api/authentication/)** for how to create one.
***
## 3. How an integration is shaped (the request lifecycle)
[Section titled “3. How an integration is shaped (the request lifecycle)”](#3-how-an-integration-is-shaped-the-request-lifecycle)
Almost every Madoo integration follows the same five-step shape. The whole guide is organised around it.
```plaintext
┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 1. Get a │ │ 2. Discover │ │ 3. Submit an │ │ 4. Poll until│ │ 5. Read the │
│ token │──▶│ the │──▶│ execution │──▶│ it is │──▶│ outputs │
│ │ │ workflow │ │ (inputs) │ │ finished │ │ │
└─────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
auth/token GET workflows/{id} POST executions GET executions/{id} (status loop)
```
1. **Authenticate** — exchange your `client_id` + `client_secret` for a short-lived bearer token. → [01-authentication.md](/public-api/authentication/)
2. **Discover the workflow** — read its interface to learn the exact input keys, their types, and which are required. → [03-workflows.md](/public-api/workflows/)
3. **Submit an execution** — POST the inputs. You get back an execution ID immediately (the work runs in the background). → [04-executions.md](/public-api/executions/)
4. **Wait for completion** — poll the execution, or configure an outbound completion webhook for a push notification. → [04-executions.md](/public-api/executions/), [11-webhooks.md](/public-api/webhooks/)
5. **Read the outputs** — download URLs, inline text/JSON, and image thumbnails. → [04-executions.md](/public-api/executions/)
If your inputs include files (images, video…), there is a half-step between 2 and 3: **upload the file first** to get a storage path, then reference that path in your inputs. → [05-assets.md](/public-api/assets/)
The **[02-quickstart.md](/public-api/quickstart/)** runs through all five steps end-to-end with working copy-paste examples. If you like to learn by doing, start there and come back here for the concepts.
***
## 4. The most important concept: the workflow *interface*
[Section titled “4. The most important concept: the workflow interface”](#4-the-most-important-concept-the-workflow-interface)
This is the idea that makes Madoo integrable in a parametric way, so it deserves its own section even in the overview. (The full treatment, with examples, is in [03-workflows.md](/public-api/workflows/).)
A workflow does not accept arbitrary data. It **declares** what it accepts and what it produces, through special **input nodes** and **output nodes** placed on its canvas by the author. Together, those declarations form the workflow’s **interface** — the contract between your code and the workflow.
When you call `GET /api/v1/workflows/{id}`, the response contains an `interface` object describing exactly:
* which **inputs** the workflow expects — each with a `name` (the key you use), a `type` (`text`, `image`, `number`…), whether it is `required`, and an optional `default_value`;
* which **outputs** the workflow produces — each with a `name` and a `type`.
```jsonc
{
"id": "wf_53fb2f6576fa4bc9a6d3c91a7e84de47",
"name": "Newsletter Hero",
"interface": {
"inputs": [
{ "name": "block_title", "type": "text", "label": "Block Title", "required": true },
{ "name": "product_image_0", "type": "image", "label": "Product Image 0", "required": true },
{ "name": "product_image_1", "type": "image", "label": "Product Image 1", "required": false }
],
"outputs": [
{ "name": "hero_image", "type": "image", "label": "Hero Image" },
{ "name": "copy_json", "type": "text", "label": "Copy JSON" }
]
}
}
```
The golden rule that follows from this:
> **Never hard-code input keys. Always read `GET /api/v1/workflows/{id}` first and use the `name` values it returns.** A workflow’s interface is the source of truth; it can change between versions, and guessing keys is the #1 cause of broken integrations.
On top of the auto-generated default interface, a workflow author can also publish one or more **custom interfaces** — curated, named subsets of inputs/outputs (with friendlier field keys, constraints, and defaults) tailored to a specific use case. You select one at execution time by its `id`. Custom interfaces are fully explained in [03-workflows.md](/public-api/workflows/).
***
## 5. Environments
[Section titled “5. Environments”](#5-environments)
Madoo runs in several environments. **They are completely isolated** — credentials, workflows, executions, and uploaded assets in one environment do not exist in another. An API key you create in Testing only works against Testing.
Pick the environment you are integrating against and use its **base URL** everywhere. Every endpoint in this guide is written as a path (e.g. `POST /api/v1/executions`); prepend the base URL of your environment to form the full URL.
| Environment | Base URL | Notes |
| --------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| **Development** | `https://localhost:5001` | Local machine only. Uses a self-signed TLS certificate — pass `-k` to curl / disable cert validation in your HTTP client. |
| **Testing** | `https://testing-api.madoo.ai` | Shared, stable test deployment. The right place to build and validate your integration. |
| **Pre-prod** | `https://preprod-api.madoo.ai` | Production-like staging for final verification. |
| **Production** | `https://api.madoo.ai` | Live environment. Real credits are consumed. |
**Full URL example** (creating an execution in Testing):
```plaintext
https://testing-api.madoo.ai/api/v1/executions
└──────────── base URL ─────┘└──── endpoint path ────┘
```
Throughout the rest of this guide we use a `BASE_URL` placeholder in examples. Set it once:
```bash
BASE_URL="https://testing-api.madoo.ai" # change to match your environment
```
### Interactive docs & OpenAPI spec
[Section titled “Interactive docs & OpenAPI spec”](#interactive-docs--openapi-spec)
Each environment also serves a live, browsable API reference and a machine-readable spec:
* **Interactive docs (Scalar UI):** `{BASE_URL}/docs`
* **OpenAPI/Swagger JSON:** `{BASE_URL}/swagger/public-v1/swagger.json`
The OpenAPI document (group `public-v1`) is generated directly from the code, so it is always an exact, up-to-date description of the request/response shapes. This guide is the *narrative* layer on top of it: use the guide to understand concepts and flows, and the OpenAPI spec to generate clients or check the precise schema of any field.
***
## 6. Conventions used across the whole API
[Section titled “6. Conventions used across the whole API”](#6-conventions-used-across-the-whole-api)
These rules apply to every endpoint. Knowing them up front means you will not be surprised later. The exhaustive reference (status codes, error code catalogue, rate-limit details) is in **[08-reference.md](/public-api/reference/)**.
### Resource IDs are prefixed strings
[Section titled “Resource IDs are prefixed strings”](#resource-ids-are-prefixed-strings)
Madoo exposes resource IDs as **prefixed, opaque strings** (Stripe-style), not raw numbers. The prefix tells you the resource type at a glance:
| Prefix | Resource | Example |
| ------ | ------------------- | -------------------------------------- |
| `wf_` | Workflow | `wf_53fb2f6576fa4bc9a6d3c91a7e84de47` |
| `run_` | Execution (a “run”) | `run_b4c8d3e29f5a4b6c8d1e2f3a4b5c6d7e` |
| `bat_` | Batch execution | `bat_9a1c…` |
| `emb_` | Embed token | `emb_7f2e…` |
Treat them as opaque: pass back whatever the API gave you. (Under the hood they are a prefix plus a GUID in compact hex form, but you should not need to parse them.)
### Authentication header
[Section titled “Authentication header”](#authentication-header)
Every endpoint except the token endpoint requires a bearer token:
```plaintext
Authorization: Bearer
```
### JSON, snake_case, ISO 8601
[Section titled “JSON, snake_case, ISO 8601”](#json-snake_case-iso-8601)
Request and response bodies are JSON. Field names are `snake_case` (e.g. `created_at`, `asset_path`). Timestamps are ISO 8601 / RFC 3339 strings in UTC (e.g. `2026-05-27T14:32:10.123+00:00`).
### Pagination (cursor-based)
[Section titled “Pagination (cursor-based)”](#pagination-cursor-based)
List endpoints return a consistent envelope and page with a cursor, not an offset:
```jsonc
{
"data": [ /* … items … */ ],
"has_more": true,
"next_cursor": "wf_…", // pass this as ?starting_after= to get the next page
"total_count": 137
}
```
* `limit` — items per page (1–100, default 25).
* `starting_after` — pass the previous page’s `next_cursor` to fetch the next page.
* Stop when `has_more` is `false`.
### Errors (RFC 7807 Problem Details)
[Section titled “Errors (RFC 7807 Problem Details)”](#errors-rfc-7807-problem-details)
Every error response uses the same JSON shape, the [RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807) “Problem Details” format:
```jsonc
{
"type": "https://docs.madoo.ai/public-api/errors/workflow-not-found",
"title": "Not Found",
"status": 404,
"detail": "Workflow wf_… not found.",
"code": "workflow_not_found", // machine-readable — branch on this
"instance": "/api/v1/executions",
"request_id": "0HMÉ…" // quote this when contacting support
}
```
Branch your error handling on the **`code`** field (stable, machine-readable), not on `detail` (human prose, may change). The full status-code and error-code catalogue is in [08-reference.md](/public-api/reference/).
### Rate limiting
[Section titled “Rate limiting”](#rate-limiting)
API calls are rate-limited per organization/plan (a sliding window; the fallback is 60 requests per minute). The token endpoint is limited more strictly. Responses carry an `X-RateLimit-Limit` header, and exceeding the limit returns **HTTP 429** with a Problem Details body (`code: too_many_requests`). Build a short backoff-and-retry into your client. Details and exact limits: [08-reference.md](/public-api/reference/).
***
## 7. Document map
[Section titled “7. Document map”](#7-document-map)
Read in order for a guided path, or jump straight to what you need.
| Document | What it covers |
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **[01-authentication.md](/public-api/authentication/)** | The two auth modes: API key / client-credentials (create a key, obtain the bearer token) and the OAuth connector flow (Authorization Code + PKCE, zero-paste) for pre-registered app connectors. |
| **[02-quickstart.md](/public-api/quickstart/)** | A complete, copy-paste, end-to-end run: token → discovery → upload → execute → poll → outputs. curl **and** JavaScript/TypeScript. |
| **[03-workflows.md](/public-api/workflows/)** | Listing workflows, reading the interface in depth, required/optional inputs, defaults, **custom interfaces**, and version pinning. |
| **[04-executions.md](/public-api/executions/)** | Submitting executions (the `value` vs `asset_path` rule, JSON double-encoding), polling, reading outputs (URLs, thumbnails, the GUID-suffix gotcha), cancelling, ZIP export. |
| **[05-assets.md](/public-api/assets/)** | Uploading files, structured-data inspection, and composing/validating portable multi-asset bundle manifests. |
| **[06-advanced-batch.md](/public-api/batch/)** | *(Advanced)* Running the same workflow over many input sets at once. |
| **[07-advanced-embed.md](/public-api/embed/)** | *(Advanced)* External embeds: workflow runtime widget, embedded workflow editor, embed tokens, iframe URLs, and `postMessage` contracts. |
| **[08-reference.md](/public-api/reference/)** | The reference appendix: HTTP status codes, error-code catalogue, rate-limit specifics, ID prefixes, status enums, plan & storage endpoints, machine discovery (`/.well-known/api-catalog`, agent-skills index). |
| **[09-catalog.md](/public-api/catalog/)** | Catalog discovery: node types, the models behind each node (with the effective per-workspace default and credit costs), presets and effects. |
| **[10-authoring.md](/public-api/authoring/)** | Workflow authoring: the public definition schema `1.0`, creating/updating/validating workflows headlessly, definition export/import, metadata and deletion, lifecycle (publish/archive/revert/clone), versions, credit estimate, `ETag`/`If-Match` concurrency and `Idempotency-Key` retries. |
| **[11-design-templates.md](/public-api/design-templates/)** | DD7 DesignDocument discovery and authoring: portable IDs, immutable revisions, semantic commands, PDF/JPEG rendering, REST v1 and MCP parity. |
| **[11-webhooks.md](/public-api/webhooks/)** | Reliable completion notifications: endpoint setup, show-once signing secret, signature verification, tests, activation, logs, retry and replay. |
For the complete cross-surface explanation of bundles—including the visual editor, REST v1, MCP, Madoo AI Agent, and the in-editor AI Assistant—see the canonical **Bundle manifest authoring guide**.
## | **[12-mcp-server.md](/public-api/mcp-server/)** | The MCP server: connecting Claude, Claude Code, ChatGPT, Codex, Cursor, VS Code, Lovable and other AI clients (OAuth, zero setup) or automation (API key), what the assistant can do, scopes and troubleshooting. |
[Section titled “| 12-mcp-server.md | The MCP server: connecting Claude, Claude Code, ChatGPT, Codex, Cursor, VS Code, Lovable and other AI clients (OAuth, zero setup) or automation (API key), what the assistant can do, scopes and troubleshooting. |”](#-12-mcp-servermd--the-mcp-server-connecting-claude-claude-code-chatgpt-codex-cursor-vs-code-lovable-and-other-ai-clients-oauth-zero-setup-or-automation-api-key-what-the-assistant-can-do-scopes-and-troubleshooting-)
## 8. The shortest possible example
[Section titled “8. The shortest possible example”](#8-the-shortest-possible-example)
To make the shape concrete, here is the entire lifecycle in eight lines of shell. (Each step is explained properly in the linked documents — this is just to show you the silhouette.)
```bash
BASE_URL="https://testing-api.madoo.ai"
WF="wf_53fb2f6576fa4bc9a6d3c91a7e84de47"
# 1. Token (HTTP Basic with your client_id:client_secret)
TOKEN=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=client_credentials" "$BASE_URL/api/v1/auth/token" | jq -r .access_token)
# 2. Discover the interface (what inputs does it want?)
curl -s -H "Authorization: Bearer $TOKEN" "$BASE_URL/api/v1/workflows/$WF" | jq .interface
# 3. Submit an execution
RUN=$(curl -s -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"workflow\":\"$WF\",\"inputs\":{\"block_title\":{\"value\":\"\\\"Spring in Bloom\\\"\"}}}" \
"$BASE_URL/api/v1/executions" | jq -r .id)
# 4. Poll until terminal, then 5. read outputs
curl -s -H "Authorization: Bearer $TOKEN" "$BASE_URL/api/v1/executions/$RUN" | jq '.status, .outputs'
```
> Notice the `\"\\\"Spring in Bloom\\\"\"` in step 3 — text input values are **JSON-encoded strings**, so the literal `Spring in Bloom` is sent as the JSON string `"Spring in Bloom"`. This “double encoding” trips up almost everyone the first time; it is explained carefully in [04-executions.md](/public-api/executions/).
Ready? Head to **[01-authentication.md](/public-api/authentication/)** to get your first token.
# Assets (file uploads)
Any workflow input of a file type — `image`, `video`, `audio`, `document`, `model3d`, or `data` — is supplied as a file, not inline. The flow is always the same: **get a storage `path` once — by uploading the file, importing it from a URL, or via a presigned upload — then reference that `path` as `asset_path` in your execution inputs.** This document covers uploading (multipart, presigned), importing from a URL, the supported formats and limits, structured-data inspection, managing assets, and composing a variable collection of assets into `madoo.bundle-manifest/v1`.
Assets live inside your workspace (the one your API key is bound to).
> For the user-facing mental model and the equivalent procedures in the editor, MCP, Madoo AI Agent, and AI Assistant, read the canonical Bundle manifest authoring guide.
***
## 1. Upload a file
[Section titled “1. Upload a file”](#1-upload-a-file)
```plaintext
POST {BASE_URL}/api/v1/assets
```
This is a `multipart/form-data` request with a single form field named **`file`**.
```bash
curl -s -X POST "$BASE_URL/api/v1/assets" \
-H "Authorization: Bearer $TOKEN" \
-F "file=@./product-hero.jpg"
```
```ts
const form = new FormData();
// In the browser/Deno you can append a File/Blob directly:
form.append("file", fileBlob, "product-hero.jpg");
const res = await fetch(`${BASE_URL}/api/v1/assets`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` }, // do NOT set Content-Type — fetch sets the multipart boundary
body: form,
});
const asset = await res.json();
```
On success you get **HTTP 201 Created**:
```jsonc
{
"path": "uploads/ws-12/a3/product-hero.jpg", // ← use THIS as asset_path
"name": "product-hero.jpg",
"content_type": "image/jpeg",
"size_bytes": 248173
}
```
The **`path`** is what matters. Pass it straight into an execution input:
```jsonc
"inputs": {
"product_image_0": { "asset_path": "uploads/ws-12/a3/product-hero.jpg" }
}
```
***
## 2. Import from a URL
[Section titled “2. Import from a URL”](#2-import-from-a-url)
If the file already lives at a public `https` URL (your own storage, a signed URL, a CDN), let Madoo fetch it for you instead of streaming the bytes through your request:
```plaintext
POST {BASE_URL}/api/v1/assets/import
```
```bash
curl -s -X POST "$BASE_URL/api/v1/assets/import" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "url": "https://cdn.example.com/product-hero.jpg" }'
```
| Field | Type | Description |
| ----------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `url` | string | **Required.** Public `https` URL of the file. The server fetches it. |
| `file_name` | string | Optional. Name (with extension) to store it under and to derive the content type. When omitted, it is taken from the URL path or the response. |
The response is **HTTP 201 Created** with the **same shape as an upload** (§1) — use `path` as `asset_path`:
```jsonc
{
"path": "uploads/ws-12/a3/product-hero.jpg",
"name": "product-hero.jpg",
"content_type": "image/jpeg",
"size_bytes": 248173
}
```
**Why use this over multipart upload?** No multipart/base64 encoding, the bytes never pass through your request body, and it comfortably handles large files. Same allowed formats and **250 MB** cap as the upload endpoint (§4); requires the `assets:write` scope, the same as upload. For a **local** large file, see also the presigned upload (§3).
> **The server fetches the URL, hardened against SSRF**: `https` only, public hosts, with per-redirect re-validation and connect-time IP pinning. The fetched bytes are **sniffed against the resolved content type** — a URL that lies about its type is rejected (`content_mismatch`). A URL that can’t be fetched returns **502** (`fetch_failed`); see the full error table in §9.
***
## 3. Presigned upload (large or local files)
[Section titled “3. Presigned upload (large or local files)”](#3-presigned-upload-large-or-local-files)
> **Availability.** This flow is enabled per environment. Where it is off, its session endpoints return **HTTP 501** (`feature_disabled`) — fall back to multipart upload (§1) or import-from-URL (§2), which are always available.
For a **large or local file** the most robust path is a presigned upload: ask the server for a short-lived, create-only upload URL, **PUT the raw bytes straight to storage** (they never pass through your API request or get base64-encoded), then finalize to get the durable `path`.
### 3.1 Create the upload session
[Section titled “3.1 Create the upload session”](#31-create-the-upload-session)
```plaintext
POST {BASE_URL}/api/v1/assets/uploads
```
| Field | Type | Description |
| ------------- | ------- | ---------------------------------------------------------------------------------------------------------------------- |
| `file_name` | string | **Required.** Name with extension; the extension sets the content type (allow-list in §4). |
| `size_bytes` | integer | **Required.** File size in bytes (quota pre-check; the actual bytes are re-validated at finalize). |
| `upload_mode` | string | `auto` (recommended), `single_put`, or `multipart`. `auto` selects resumable multipart above the configured threshold. |
Optionally send an **`Idempotency-Key`** header (8–255 characters): the same key with the same file replays the same session; reusing it with a *different* file is rejected (`409 idempotency_conflict`).
```bash
curl -s -X POST "$BASE_URL/api/v1/assets/uploads" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: render-2026-06-25-001" \
-d '{ "file_name": "render.mp4", "size_bytes": 52428800, "upload_mode": "auto" }'
```
**HTTP 200** with `status: "pending"`:
```jsonc
{
"status": "pending",
"upload_id": "9f8c…", // ← pass to finalize
"file_name": "render.mp4",
"content_type": "video/mp4",
"max_size_bytes": 262144000,
"strategy": "single_put",
"upload_url": "https://…/staging/…?sig=…", // ← PUT the bytes here
"upload_method": "PUT",
"required_headers": { "x-ms-blob-type": "BlockBlob" }, // send each verbatim
"url_expires_at": "2026-06-25T10:10:00Z", // PUT before this
"finalize_expires_at": "2026-06-25T11:00:00Z" // finalize before this
}
```
An idempotent retry can also return `status: "in_progress"` (a finalize is already running — just retry finalize) or `status: "finalized"` (already done — `path` + `size_bytes` are returned directly, skip straight to using it).
For `strategy: "multipart"`, there is no whole-file `upload_url`. The response instead includes:
```jsonc
{
"status": "pending",
"upload_id": "9f8c…",
"strategy": "multipart",
"part_size_bytes": 33554432,
"total_parts": 7,
"checksum_algorithm": "md5",
"recommended_parallel_parts": 4,
"recommended_max_in_flight_bytes": 134217728,
"finalize_expires_at": "2026-06-28T10:00:00Z"
}
```
### 3.2a Resumable multipart transfer
[Section titled “3.2a Resumable multipart transfer”](#32a-resumable-multipart-transfer)
For each missing part, calculate its base64 MD5 and request a short-lived grant. The last part has the exact remaining size; all earlier parts have `part_size_bytes`:
```http
POST /api/v1/assets/uploads/{upload_id}/parts:sign
Content-Type: application/json
{ "parts": [{ "part_number": 1, "size_bytes": 33554432, "checksum_base64": "…" }] }
```
PUT the part bytes directly to the returned `upload_url`, sending every `required_headers` entry verbatim. Keep no signed URL: ask for a fresh one when retrying. Upload up to `recommended_parallel_parts`, while respecting `recommended_max_in_flight_bytes`.
To resume, call `GET /api/v1/assets/uploads/{upload_id}`. Madoo reconciles provider receipts and returns each part with `confirmed: true|false`; resend only missing parts. The provider receipt, not a client confirmation call, is authoritative.
After all parts are confirmed, call `POST /api/v1/assets/uploads/{upload_id}:complete`. A `202` with `status: "in_progress"` means assembly succeeded and the worker is validating/publishing the asset. Poll the GET status endpoint until `finalized` and use its `path`. Completion and polling are idempotent. To discard an unfinished session, call `POST /api/v1/assets/uploads/{upload_id}:abort`.
### 3.2 PUT the bytes (single PUT)
[Section titled “3.2 PUT the bytes (single PUT)”](#32-put-the-bytes-single-put)
PUT the raw file bytes to `upload_url`, sending **every** `required_headers` entry verbatim. The bytes go directly to storage, not through Madoo:
```bash
curl -s -X PUT "$UPLOAD_URL" \
-H "x-ms-blob-type: BlockBlob" \
--data-binary "@./render.mp4"
```
### 3.3 Finalize (single PUT)
[Section titled “3.3 Finalize (single PUT)”](#33-finalize-single-put)
```plaintext
POST {BASE_URL}/api/v1/assets/uploads/{upload_id}/finalize
```
```bash
curl -s -X POST "$BASE_URL/api/v1/assets/uploads/9f8c…/finalize" \
-H "Authorization: Bearer $TOKEN"
```
The server validates the staged bytes (size, content sniff, quota) and promotes them into the asset area. **HTTP 200** with `status: "finalized"` — use `path` as `asset_path`:
```jsonc
{
"status": "finalized",
"upload_id": "9f8c…",
"file_name": "render.mp4",
"content_type": "video/mp4",
"path": "uploads/ws-12/9f/render.mp4", // ← use THIS as asset_path
"size_bytes": 52428800
}
```
Finalize is **idempotent**: re-finalizing a completed upload replays the same result; one still being finalized returns `status: "in_progress"` (retry shortly).
**Which upload should I use?** Multipart (§1) is simplest for small files already in hand. Import-from-URL (§2) is best when the file already lives at a URL. Presigned upload (this section) is the most robust for large or local files — the bytes go straight to storage, out of band, and multipart retries only failed parts. All three require the `assets:write` scope and currently share the same formats and **250 MB** cap (§4). The multipart contract is deliberately independent of this cap so it can be raised after the large-media execution path is file-backed and qualified.
***
## 4. Supported formats and size limit
[Section titled “4. Supported formats and size limit”](#4-supported-formats-and-size-limit)
**Maximum file size: 250 MB.** A larger file is rejected with **HTTP 413** (`file_too_large`).
The file type is validated by **extension**. Allowed types:
| Category | Extensions |
| ---------------- | ----------------------------------------------------------------------------------- |
| Images | `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.avif`, `.svg`, `.bmp`, `.tiff` / `.tif` |
| Documents / data | `.pdf`, `.json`, `.csv`, `.xlsx`, `.txt`, `.md` |
| Video | `.mp4`, `.webm`, `.mov` |
| Audio | `.mp3`, `.wav`, `.ogg`, `.flac`, `.aac`, `.m4a` |
| 3D | `.glb`, `.gltf`, `.obj`, `.fbx`, `.stl`, `.usdz` |
An unsupported extension is rejected with **HTTP 400** (`invalid_file_type`); an empty/missing file with **400** (`invalid_file`).
> Match the file’s extension to its real content. The extension selects the allowed canonical type; finalization then inspects the bytes and rejects a mismatch before the asset becomes usable.
**SVG files are cleaned when stored.** An SVG is a document that can run scripts and load content from elsewhere, so every SVG that enters Madoo — uploaded, imported, finalized, or produced by a node — is stored without what can act or reach outside the file: scripts, event handlers (`onload`, `onclick`, …), animations, embedded web content (`foreignObject`, `iframe`), and links or styles pointing outside the file. Links inside the file (`#id`) and embedded PNG/JPEG/GIF/WebP images stay, so the drawing looks the same. A file with the `.svg` extension that is not a well-formed SVG document is rejected with `invalid_content` (upload) or `content_mismatch` (import, finalize); a compressed `.svgz` is not accepted.
SVG stays vector in document templates and in image outputs: `output/image` with format `original` (the default) stores it as `.svg`. The other formats of `output/image` — `png`, `jpg`, `webp` — really convert the image, SVG or raster: the stored bytes, the extension and the content type all match, and `jpg`, which has no transparency, is flattened on white. Nodes that need pixels — AI nodes, image editing (resize, crop, filters, …), video overlays — receive a transparent PNG made from the SVG, 2048 px on its longest side, drawn the same way as in templates. An SVG the renderer cannot draw (an embedded ``, more than 2,000 elements) fails those nodes with the reason, and in a template counts as a failed image in the layout report instead of leaving a silent empty box. AI models that produce SVG (vectorization) have their result stored as `.svg`.
***
## 5. Use an asset as an input
[Section titled “5. Use an asset as an input”](#5-use-an-asset-as-an-input)
Once uploaded, the `path` can be reused across as many executions as you like — upload a product photo once, run ten workflows against it. There is no separate “register” step; the `path` *is* the handle.
```ts
// Upload, then run.
const { path } = await (await fetch(`${BASE_URL}/api/v1/assets`, {
method: "POST", headers: { Authorization: `Bearer ${token}` }, body: form,
})).json();
await fetch(`${BASE_URL}/api/v1/executions`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
workflow: WF,
inputs: { product_image_0: { asset_path: path } },
}),
});
```
> **Pattern: pull a file from a URL you already have.** If your image lives at a URL (your own storage, a signed URL, a CDN), don’t download-and-re-upload it yourself — use [§2 Import from a URL](#2-import-from-a-url) and let the server fetch it directly.
### 5.1 Inspect a CSV, JSON or XLSX dataset before authoring
[Section titled “5.1 Inspect a CSV, JSON or XLSX dataset before authoring”](#51-inspect-a-csv-json-or-xlsx-dataset-before-authoring)
```plaintext
POST {BASE_URL}/api/v1/structured-data/inspect
```
Use this authoring endpoint after upload and before configuring a data-driven workflow. It reads the workspace-owned asset through the same tenant/finalization gate used by execution, then returns the portable `madoo.dataset-profile/v1` shape: exact row count, columns, probable logical types, a bounded sample and any warnings. It does not run a workflow and does not consume AI credits.
```json
{
"asset_path": "org-token/ws-token/assets/products.csv",
"format": "auto",
"header_row": 1,
"locale": "it-IT",
"sample_rows": 10,
"max_rows": 100000,
"delimiter": ";"
}
```
For JSON, set `table` to the returned array path (for example `$.products`) when the document has more than one candidate array. For a root array the selected table is `$`. Nested objects and arrays remain JSON values: inspection never flattens them into invented columns. CSV header names are trimmed and compared case-insensitively; duplicates are rejected because a later mapping would be ambiguous.
How CSV cells become typed values:
* **Numbers follow `locale`, and a number is read only when it is unambiguous.** With the default `invariant`, only plain numbers with a dot are numbers (`19.90`, `-3`, `1500`). With a locale, its decimal separator and correctly placed group separators are read: `it-IT` reads `1.234,50` and `19,90`, `en-US` reads `1,234.50`. Anything else stays **text**, never a silently different number: `19,90` without a locale is the text `"19,90"` (not 1990), and `3.5` under `it-IT` is the text `"3.5"`. Each column holding such values gets a `CSV_AMBIGUOUS_NUMBER` warning naming the column (`path`) and a sample; set the file’s locale and inspect again.
* Values with a leading zero (`001`, `00501`) stay text: they are codes, not numbers.
* `true` / `false` are booleans in any letter case (`TRUE`, `False`).
* Rows whose every cell is empty (`;;;`, what a spreadsheet exports for formatted empty rows) are not data: they are skipped and not counted.
* The file is read as UTF-8 (with or without BOM). A file that is not UTF-8 is read as Windows-1252 — what Excel saves as “CSV” in Western locales — so `à` and `€` survive, and the profile carries a `CSV_WINDOWS_1252` warning. Saving as “CSV UTF-8” removes the warning.
For XLSX, `table` is the worksheet name. It can be omitted only when there is exactly one visible worksheet; hidden worksheets are reported but selected only explicitly. `formula_policy` is either `cached_value` (default: read the value saved by Excel and emit a warning) or `reject`. Madoo never executes formulas, macros, external links or data connections. `.xls` and `.xlsm` are rejected.
The general upload cap remains 250 MB. XLSX inspection has a stricter 64 MB compressed-package cap and also checks entry count, expanded size, individual entries and compression ratio before Open XML reads the workbook. The endpoint requires both `assets:read` scope and `ws:assets:read` permission.
After inspection, configure an `input/data` node with the same options (`format`, `table`, `headerRow`, `locale`, `formulaPolicy`, `emptyBehavior`, `maxRows`, and optional CSV `delimiter`). At execution time its `data` input accepts the uploaded `asset_path`; a runtime asset overrides any authoring-time default. The node is intentionally scalar: it emits a portable `madoo.dataset/v1` manifest, a `madoo.dataset-profile/v1` profile, and `row_count`. Row fan-out is a separate operation, so loading a spreadsheet alone does not unexpectedly multiply downstream work or its credit estimate.
For row fan-out, connect `input/data.dataset` to `enumerate/data_rows.dataset`. Its `rowMapping.columns` array uses exact `sourceColumn` names from the inspection profile, stable `outputPort` graph handles, an `outputType` (`text`, `number`, `boolean`, `json`, `url`, or `any`), `selected`, and strict conversion. An empty cell is `null` on a `number`, `boolean` or `url` port (not a failed row); a `text` port accepts cells read as numbers or booleans and passes their digits unchanged (a numeric SKU such as `12345`). Every selected row also emits the complete `row` values object, original zero-based `row_index`, and deterministic `row_id`. The row-store scope, byte length, SHA-256 digest, row indices, presence metadata, and declared row count are verified before iterations are admitted.
### 5.2 Compose a portable bundle manifest
[Section titled “5.2 Compose a portable bundle manifest”](#52-compose-a-portable-bundle-manifest)
```plaintext
POST {BASE_URL}/api/v1/bundle-manifests/compose
```
This is the normal high-level path. Upload the files first, then provide only their returned paths and the human/business metadata that matters to the workflow:
Do not calculate `storageRef`, MIME, byte length, or SHA-256 in your client. The same Composer used by all Madoo authoring surfaces owns those technical fields. `id`, `relative_path`, and `metadata` are logical information; the canonical guide explains their exact roles and why storage references are portable opaque paths rather than public URLs.
```json
{
"bundle_id": "property-roma-001",
"include_checksum": true,
"assets": [
{
"asset_path": "org-token/ws-token/assets/.../kitchen-01.png",
"file_name": "kitchen-01.png",
"metadata": { "role": "interior", "room": "kitchen" }
}
]
}
```
`file_name`, `id`, `relative_path`, and `metadata` are optional. When omitted, Madoo derives a stable logical id and path from the stored file name. It always verifies workspace ownership and reads authoritative storage metadata; with `include_checksum: true` (the default) it also streams the bytes once and adds SHA-256. The response is immediately usable as a saved node parameter or runtime value.
Composition is read-only: it does not upload, copy, persist, execute, or consume AI credits. The editor’s **Build bundle** mode and the `compose_bundle_manifest` MCP/Agent/Assistant tools use this same application service.
See Bundle manifest authoring — REST v1 for the complete upload → compose → validate → save/override sequence.
### 5.3 Validate a portable bundle manifest
[Section titled “5.3 Validate a portable bundle manifest”](#53-validate-a-portable-bundle-manifest)
```plaintext
POST {BASE_URL}/api/v1/bundle-manifests/validate
```
Use a bundle when the number and roles of uploaded assets vary per execution. Upload every file with the normal asset API, then describe the collection with one `madoo.bundle-manifest/v1`:
```json
{
"manifest": {
"schema": "madoo.bundle-manifest/v1",
"bundleId": "property-roma-001",
"assets": [
{
"id": "kitchen-01",
"relativePath": "photos/kitchen-01.png",
"storageRef": "org-token/ws-token/assets/kitchen-01.png",
"mediaType": "image/png",
"sizeBytes": 248120,
"sha256": "64-lowercase-hex-characters",
"metadata": { "role": "interior", "room": "kitchen" }
}
]
},
"validation": "fail",
"verify_checksum": "if_present"
}
```
The endpoint applies the same service as `input/bundle_manifest`: JSON Schema, unique IDs and logical paths, portable path rules, finalized-upload and workspace ownership checks, authoritative size/MIME, content signature, and SHA-256 according to `always`, `if_present`, or `never`. It consumes no AI credits. The response contains the normalized `bundle`, `supplied_asset_count`, accepted `asset_count`, `valid`, and portable `issues`.
The node parameter names are camelCase: `validation`, `verifyChecksum`, and the technical-only `allowExternalUrls=false`. In `fail` mode an invalid result stops execution. In `warn` mode rejected entries are omitted from the normalized bundle and reported with `valid=false`; unauthorized or temporarily unverifiable storage never becomes a warning. Pass the complete manifest as the input’s inline JSON `value`, not as an `asset_path`. Use `enumerate/json` on `bundle.assets` when downstream work must fan out over the accepted assets.
***
## 6. List assets
[Section titled “6. List assets”](#6-list-assets)
```plaintext
GET {BASE_URL}/api/v1/assets
```
| Query param | Type | Description |
| ---------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `limit` | integer | 1–100, default 25. |
| `prefix` | string | Return only paths under this prefix. |
| `name` | string | Return only files whose name contains this text (case-insensitive), e.g. `lamp` or `.pdf`. `total_count` counts the matches. |
| `starting_after` | string | Pagination cursor: the `path` of the last item from the previous page (returned as `next_cursor`). |
```bash
curl -s -H "Authorization: Bearer $TOKEN" "$BASE_URL/api/v1/assets?prefix=uploads/ws-12/"
```
Returns the standard paginated envelope of asset objects (`path`, `name`, `content_type`, `size_bytes`, `created_at`).
> **Listing is lightweight.** To stay fast, the list does not fetch per-file metadata, so `size_bytes` and `created_at` are placeholders in list results. When you need authoritative size and content type for a specific file, rely on the values returned by the **upload** response (§1) or keep your own record keyed by `path`.
***
## 7. Get a download URL
[Section titled “7. Get a download URL”](#7-get-a-download-url)
```plaintext
GET {BASE_URL}/api/v1/assets/download-url?path={path}
```
Returns a public URL to fetch the file you uploaded.
```bash
curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/api/v1/assets/download-url?path=uploads/ws-12/a3/product-hero.jpg"
# → { "url": "https://cdn-testing.madoo.ai/…/product-hero.jpg" }
```
As with output URLs, these are public and non-guessable but unauthenticated — see the privacy note in [04-executions §6](/public-api/executions/#-privacy--lifetime-of-output-urls).
***
## 8. Delete an asset
[Section titled “8. Delete an asset”](#8-delete-an-asset)
```plaintext
DELETE {BASE_URL}/api/v1/assets?path={path}
```
```bash
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/api/v1/assets?path=uploads/ws-12/a3/product-hero.jpg"
```
Returns **HTTP 204 No Content** on success, **404** if the path does not exist.
> Deleting an asset that a past execution used does not retroactively affect that execution’s already-generated outputs — but a future execution referencing the deleted `path` will fail. Clean up only assets you no longer need as inputs.
***
## 9. Errors
[Section titled “9. Errors”](#9-errors)
| HTTP | `code` | Cause |
| ---- | ------------------------- | ------------------------------------------------------------------------------------------------------------- |
| 400 | `invalid_file` | No file provided or the file is empty (upload). |
| 400 | `invalid_file_type` | Extension not in the allowed list (§4) (upload). |
| 400 | `invalid_request` | Missing/invalid `path` parameter, missing `url` on import, or missing `file_name` on presigned create. |
| 400 | `invalid_url` | Import: the `url` is missing, malformed, or not allowed by the fetch policy. |
| 400 | `unsupported_type` | Import / presigned create: the resolved type is not on the allow-list. |
| 400 | `content_mismatch` | Import / finalize: the bytes don’t match the resolved content type. |
| 400 | `invalid_content` | Upload: the file cannot be stored as its type — for example an `.svg` that is not a well-formed SVG document. |
| 400 | `invalid_size` | Presigned create: `size_bytes` is non-positive or over the cap. |
| 400 | `invalid_idempotency_key` | Presigned create: the `Idempotency-Key` header is malformed (not 8–255 chars, or has control characters). |
| 402 | `quota_exceeded` | Storage quota would be exceeded. |
| 403 | `forbidden` | The asset belongs to a different workspace. |
| 404 | `not_found` | No asset at that `path`, or no presigned upload with that id for your workspace. |
| 409 | `idempotency_conflict` | Presigned create: the `Idempotency-Key` was reused with a different file. |
| 409 | `window_closing` | Presigned create: too close to the finalize deadline — start a new upload. |
| 409 | `file_not_uploaded` | Finalize: the bytes were never PUT to the upload URL. |
| 410 | `upload_expired` | Finalize: the upload window elapsed — start a new upload. |
| 412 | `precondition_failed` | Finalize: the staged bytes changed since validation — re-upload and finalize. |
| 413 | `file_too_large` | File exceeds 250 MB (upload, import, or finalize). |
| 429 | `rate_limited` | Import / presigned create is temporarily throttled — retry shortly. |
| 501 | `feature_disabled` | Presigned upload is not enabled for this environment — use §1 or §2. |
| 502 | `fetch_failed` | Import: the source URL could not be fetched (HTTP error, timeout, connection). |
Storage usage and quota for your workspace are reported by `GET /api/v1/storage/usage` and `GET /api/v1/storage/quota` — see [08-reference.md](/public-api/reference/).
## 10. Workspace fonts
[Section titled “10. Workspace fonts”](#10-workspace-fonts)
Document templates print with the built-in font families and with fonts your workspace uploads. The font catalogue is read with `assets:read` and changed with `assets:write` (MCP: `list_fonts`).
```plaintext
GET {BASE_URL}/api/v1/fonts?query=playfair&category=serif&scope=workspace
```
All parameters are optional: `query` matches family and display name, `category` is `sans-serif`, `serif`, `monospace`, `display` or `handwriting`, `scope` is `system` or `workspace`. The response lists every family with its `familyGuid`, `family` (the name a template’s `fontFamily` uses), `displayName`, `category`, `scope`, the current `versionGuid` and `revisionNumber`, and its `files`: one per variant with `variant`, `weight`, `style`, `format`, `sha256`, a `downloadUrl` and the portable `reference` (`madoo.font-reference/v1`) that pins that exact file in a template.
Upload a private font as one family, with one TTF or OTF file per variant; the regular variant (weight 400, normal style) is required:
```bash
curl -s -X POST "$BASE_URL/api/v1/fonts" \
-H "Authorization: Bearer $TOKEN" \
-F "displayName=Righteous" -F "category=display" -F "licenseAcknowledged=true" \
-F "files=@Righteous-Regular.ttf"
```
`licenseAcknowledged` must be `true`: you confirm that the licence allows the workspace to use the font, and the acknowledgement is recorded. `systemFallbackFamilyGuid` optionally names a built-in family to print with if the file ever becomes unavailable. A file may be up to 20 MB, a family up to 60 MB; web formats (WOFF, WOFF2), variable fonts and collections are rejected. Every file is validated (tables, glyphs, names) and scanned before it is stored. The response (`201`) returns the `familyGuid`, `versionGuid`, `revisionNumber`, `family`, `fingerprint` and `fileCount`. (The font endpoints use camelCase names.)
| Operation | Endpoint |
| --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| New revision of a family (earlier revisions stay, templates that pin them keep printing them) | `POST /api/v1/fonts/{familyGuid}/revisions` (same form as the upload) |
| Archive a family for new authoring (referenced revisions stay available) | `POST /api/v1/fonts/{familyGuid}/archive` |
| Download one exact file | `GET /api/v1/fonts/{familyGuid}/versions/{versionGuid}/files/{fileGuid}` |
| Download the file of a variant | `GET /api/v1/fonts/{familyGuid}/versions/{versionGuid}/variants/{variant}` |
Errors use the `FONT_*` codes — for example `FONT_REGULAR_REQUIRED`, `FONT_LICENSE_ACKNOWLEDGEMENT_REQUIRED`, `FONT_VARIABLE_UNSUPPORTED`, `FONT_WEB_FORMAT_UNSUPPORTED`, `FONT_FAMILY_ALREADY_EXISTS`.
***
**Next:** [06-advanced-batch.md](/public-api/batch/) *(advanced)* — running one workflow over many input sets at once.
# Authentication
Madoo’s Public API supports **two authentication modes**, both ending in a JWT **Bearer** access token you send on every request:
1. **API key / client-credentials** (this document, §1–§5) — the standard machine-to-machine flow. Your application holds a pair of secrets (a `client_id` and a `client_secret`), exchanges them for a short-lived access token, and sends that token on every request. Best for “Any API” / server-to-server integrations and manual setups.
2. **OAuth connector** (§6) — OAuth 2.0 Authorization Code + PKCE for a **pre-registered confidential app connector** (e.g. a no-code gateway acting on a user’s behalf). The user authorizes once; the connector then runs unattended on a rotating refresh token. This is the **zero-paste**, preferred mode for native app connectors. The same capability scopes (§3.2) and the same `/api/v1` surface apply to both modes.
This document first covers the API-key mode end-to-end (the two things you do once — create a key — and the one thing you do continuously — obtain tokens), then the OAuth connector mode in §6.
> **Prerequisite:** decide which environment you are targeting and note its base URL (see [README §5](/public-api/#5-environments)). Credentials are environment-specific — a key created in Testing will not authenticate against Production.
***
## 1. Create an API key
[Section titled “1. Create an API key”](#1-create-an-api-key)
An **API key** is the credential pair your application uses. You create it once, from the Madoo dashboard, in the organization/workspace you want your integration to act in.
> The dashboard UI is the recommended path. Under the hood the dashboard calls Madoo’s authenticated application API (`POST /api/api-keys`); that endpoint is part of the internal app surface, **not** the Public API v1, so you do not call it from your integration — you use the key it produces.
### Steps
[Section titled “Steps”](#steps)
1. Sign in to the Madoo dashboard for your environment as an **organization admin/owner** (for an org-level key) or a **workspace admin** (for a workspace-level key).
2. Open the **API keys** section of your workspace (or organization) settings.
3. Create a new key, giving it a descriptive name (e.g. `acme-newsletter-integration`).
4. Madoo returns two values:
* **`client_id`** — a public identifier for the key. Not secret.
* **`client_secret`** — the secret half of the pair.
> ⚠️ **The `client_secret` is shown only once, at creation time.** Madoo stores only a hash of it and can never display it again. Copy it immediately into your secret manager. If you lose it, revoke the key and create a new one.
### Workspace-level vs organization-level keys
[Section titled “Workspace-level vs organization-level keys”](#workspace-level-vs-organization-level-keys)
Every key is ultimately scoped to **one workspace** — that is the workspace your API calls will operate in.
| Key type | Created by | Scope | When to use |
| ---------------------- | ------------------------------ | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| **Workspace-level** | Workspace admin (or org admin) | Bound to one specific workspace | **The default choice.** Use when your integration works inside a single project/brand. |
| **Organization-level** | Org admin/owner | Resolves to the organization’s *default* workspace | Use only if you specifically need an org-scoped credential; it still acts inside the default workspace. |
For almost all integrations, create a **workspace-level key** for the workspace that holds the workflows you want to run.
### Optional: restrict a key to specific IPs
[Section titled “Optional: restrict a key to specific IPs”](#optional-restrict-a-key-to-specific-ips)
A key can carry an **IP allowlist** (`AllowedIPs`). When set, the key only works from those addresses — both the token exchange and every authenticated request from a non-allowed IP are rejected with **401** `IP_NOT_ALLOWED`. Leave it empty (the default) to allow any IP.
* Format: one or more entries separated by comma/space — each an exact IP or a CIDR range, IPv4 or IPv6 (e.g. `203.0.113.7, 198.51.100.0/24, 2001:db8::/32`). A malformed value is rejected when the key is created.
* Use this only when your integration calls from **stable, known egress IPs**. Gateway-based connectors usually have stable egress; a serverless/runtime caller may not — leave the allowlist empty there.
### Store the credentials safely
[Section titled “Store the credentials safely”](#store-the-credentials-safely)
Treat the `client_secret` like a password:
* Keep it in a secret manager / environment variable — **never** commit it to source control or ship it in client-side code (browser, mobile app). The Public API is a **server-to-server** API.
* If a secret is ever exposed, revoke the key from the dashboard (revocation takes effect immediately) and issue a new one.
```bash
# Example: keep credentials in environment variables
export MADOO_CLIENT_ID="ck_live_…"
export MADOO_CLIENT_SECRET="cs_live_…"
export MADOO_BASE_URL="https://testing-api.madoo.ai"
```
***
## 2. Exchange credentials for an access token
[Section titled “2. Exchange credentials for an access token”](#2-exchange-credentials-for-an-access-token)
Send a `POST` to the token endpoint with `grant_type=client_credentials`. The request body is `application/x-www-form-urlencoded` (a form, not JSON — this is what the OAuth2 standard requires).
```plaintext
POST {BASE_URL}/api/v1/auth/token
```
You can present your credentials in **either** of two standard ways. Pick one — do not send both.
### Method A — HTTP Basic (recommended)
[Section titled “Method A — HTTP Basic (recommended)”](#method-a--http-basic-recommended)
Put `client_id:client_secret` in the `Authorization` header as Base64 (`client_secret_basic` in OAuth2 terms). Most HTTP clients do the Base64 encoding for you.
```bash
curl -s -X POST "$MADOO_BASE_URL/api/v1/auth/token" \
-u "$MADOO_CLIENT_ID:$MADOO_CLIENT_SECRET" \
-d "grant_type=client_credentials"
```
(`curl -u user:pass` builds the `Authorization: Basic …` header automatically.)
### Method B — credentials in the form body
[Section titled “Method B — credentials in the form body”](#method-b--credentials-in-the-form-body)
Put the credentials in the form body alongside the grant type (`client_secret_post`):
```bash
curl -s -X POST "$MADOO_BASE_URL/api/v1/auth/token" \
-d "grant_type=client_credentials" \
-d "client_id=$MADOO_CLIENT_ID" \
-d "client_secret=$MADOO_CLIENT_SECRET"
```
### The response
[Section titled “The response”](#the-response)
On success you get **HTTP 200** with:
```json
{
"access_token": "eyJhbGciOiJ…",
"token_type": "Bearer",
"expires_in": 3600
}
```
* **`access_token`** — the JWT to send on every subsequent request.
* **`token_type`** — always `Bearer`.
* **`expires_in`** — lifetime in **seconds** (3600 = **one hour**).
The token already encodes your organization and workspace context, so you never pass those explicitly — every call made with this token automatically operates inside the key’s workspace.
***
## 3. Use the token
[Section titled “3. Use the token”](#3-use-the-token)
Send it as a bearer token on every Public API request (everything except the token endpoint itself):
```bash
curl -s "$MADOO_BASE_URL/api/v1/workflows" \
-H "Authorization: Bearer $ACCESS_TOKEN"
```
Calling any endpoint without a valid token — or with one that has expired — returns **HTTP 401 Unauthorized**.
> **Workspace context is required.** Public API endpoints are protected by a policy that demands a valid workspace context, which your token always carries. If you somehow present a token with no workspace context, protected endpoints return **HTTP 403 Forbidden**. With a normally-issued API key token this never happens — it is mentioned only so the 403 is not a mystery.
***
## 3.1 Verify the credential — `GET /api/v1/me`
[Section titled “3.1 Verify the credential — GET /api/v1/me”](#31-verify-the-credential--get-apiv1me)
A cheap, read-only **connection test**: it confirms the token works and reports what it can do, with no side effects. Use it to validate a stored credential and to show “you’re connected” in a UI.
```bash
curl -s "$MADOO_BASE_URL/api/v1/me" -H "Authorization: Bearer $ACCESS_TOKEN"
```
```jsonc
{
"organization": { "name": "Acme", "role": "Owner" },
"workspace": { "name": "Marketing", "role": "Admin" },
"credential": {
"type": "client_credentials",
"scopes": ["workflows:execute", "executions:read"],
"legacy_unscoped": false
},
"permissions": ["ws:executions:create", "ws:executions:read"],
"api_access": { "enabled": true },
"user": null,
"limits": null
}
```
Notes:
* **No tenant/user ids are returned** — organization and workspace are identified by **name** (your credential is already bound to exactly one workspace, so it is the key you store).
* **`user` is `null` for API-key credentials.** An API key authenticates as a workspace system account, not a person; the `user` block is populated only for credentials that carry a real interactive user.
* **`credential.legacy_unscoped`** is `true` for an older API key created before scopes existed: it is grandfathered into full capability (`scopes` will be empty). Newer keys list their `scopes`.
* **`api_access.enabled`** reflects whether your plan includes API access.
* **`limits`** is reserved and currently always `null`.
***
## 3.2 Capability scopes
[Section titled “3.2 Capability scopes”](#32-capability-scopes)
Beyond *who* you are (workspace + role), a key declares *what it is allowed to do* through a small set of **capability scopes**. You choose them when you create the key in the dashboard; the token carries them, and each Public API endpoint requires the matching scope. This is least-privilege on top of RBAC: a request must satisfy **both** the endpoint’s required scope **and** the workspace-role permission.
A credential missing the required scope is rejected with **HTTP 403** and `code` `missing_scope` — a different failure from RBAC’s `403 FORBIDDEN` (role permission) so you can tell the two apart.
> **Older keys keep working.** A key created before scopes existed carries no scopes and is **grandfathered**: it is allowed through every scope gate unchanged (`/me` reports `credential.legacy_unscoped: true`). Scope enforcement only ever applies to keys that *declared* scopes. To adopt least-privilege, create a new key with exactly the scopes your integration needs.
### The scopes
[Section titled “The scopes”](#the-scopes)
| Scope | Grants |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `catalog:read` | Browse the catalog: node types, models, presets, effects, capabilities. |
| `workflows:read` | List/read workflows, definitions, versions; validate and estimate. |
| `workflows:write` | Create, update, publish, archive, revert, clone, delete workflows. |
| `workflows:execute` | Start and cancel executions (single and batch); mint runtime embed tokens. |
| `executions:read` | List/read executions and batches, results, outputs, and packaged zips. |
| `assets:read` | List assets and get download links. *(Satisfied by `assets:write` too.)* |
| `assets:write` | Upload assets, import from URL, delete. Also satisfies `assets:read`. |
| `design-templates:read` | Read document templates and their drafts, previews and publish readiness ([11-design-templates.md](/public-api/design-templates/)). |
| `design-templates:write` | Author, publish, archive document templates. Also satisfies `design-templates:read`. |
| `webhooks:read` | List webhook subscriptions and deliveries ([11-webhooks.md](/public-api/webhooks/)). |
| `webhooks:write` | Create, update, delete webhook subscriptions. |
### Required scope per endpoint family
[Section titled “Required scope per endpoint family”](#required-scope-per-endpoint-family)
| Endpoint family | Required scope |
| ------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
| `GET /capabilities`, `/node-types`, `/models`, `/presets`, `/effects` | `catalog:read` |
| `GET /workflows`, `/workflows/{id}`, `/{id}/definition`, `/{id}/versions`; `POST /{id}/validate`, `/{id}/estimate` | `workflows:read` |
| `POST /workflows`; `PUT /{id}/definition`; `PATCH /{id}`; `DELETE /{id}`; `POST /{id}/publish`, `/archive`, `/revert`, `/clone` | `workflows:write` |
| `POST /executions`, `/executions/{id}/cancel`; `POST /batch-executions`, `/{id}/start`, `/cancel`, `/retry-failed` | `workflows:execute` |
| `GET /executions…`, `/batch-executions…` (list/get/result/outputs/zip); `POST …/zip` (packaging) | `executions:read` |
| `GET /assets`, `/assets/download-url` | `assets:read` |
| `POST /assets` (upload), `DELETE /assets` | `assets:write` |
| `POST /embed/tokens` (runtime), `DELETE /embed/tokens/{jti}` | `workflows:execute` |
| `POST /embed/editor/tokens` | `workflows:write` |
| `GET /me`, `/storage/*`, `/plan`, `.well-known/*`, `POST /auth/token` | *(no capability scope)* |
> The same scope vocabulary backs both the REST API and the MCP server, and the consent screen of an OAuth connector. `assets:write` deliberately **implies** `assets:read`, so a key that can upload can also list/download without holding both scopes.
***
## 3.3 Two gates: RBAC permissions vs capability scopes
[Section titled “3.3 Two gates: RBAC permissions vs capability scopes”](#33-two-gates-rbac-permissions-vs-capability-scopes)
Every authenticated call passes through **two independent authorization gates**, and it succeeds only if **both** allow it. They answer different questions:
* **RBAC** (role-based access control) — *“does the role behind this credential allow the action?”* Your API key acts inside a workspace with a role (Owner / Admin / Editor / Viewer); the role grants a set of **permissions** (e.g. *manage workflows*, *create executions*). This is about **who** the credential acts as.
* **Capability scope** — *“was **this credential** granted this capability?”* When you create the key you pick its scopes (`workflows:execute`, `assets:write`, …; see [§3.2](#32-capability-scopes)). This is about **what this specific key is allowed to do**, independent of how powerful its role is.
Think of RBAC as your **job title** (a Director may sign cheques) and a scope as a **limited power of attorney** you hand to one integration (*“this key may only run workflows, never edit them”*) — even if the role behind it could do far more. The effective power of a credential is the **intersection**: `RBAC ∩ scope`.
**Why both?** RBAC alone can’t express *“a key that authenticates as a workspace admin but may only run workflows”* — the admin role can do everything. Scopes let you issue a **least-privilege** key: powerful by role, deliberately narrowed by scope. Conversely, scope alone isn’t enough — if the underlying role lacks the permission, the action is still denied.
**Worked example.** A workspace **Admin** (full RBAC) creates a key for a connector, scoped to `{workflows:execute, executions:read}`:
| Request | RBAC (role) | Scope (this key) | Result |
| ----------------------------- | ----------- | ------------------------- | ----------------------- |
| `POST /executions` (run) | ✓ Admin | ✓ has `workflows:execute` | **200/202** |
| `GET /executions/{id}` (read) | ✓ Admin | ✓ has `executions:read` | **200** |
| `POST /workflows` (create) | ✓ Admin | ✗ no `workflows:write` | **403 `missing_scope`** |
The role *would* allow creating a workflow; the **scope** is what stops it — exactly the least-privilege you asked for. The two failures are distinguishable: a role/permission gap returns **403 `FORBIDDEN`**, a scope gap returns **403 `missing_scope`**.
> **Grandfathering (legacy API keys only).** API keys created before scopes existed declare **no** scopes. To avoid breaking them, the REST scope gate lets a scope-less **API-key** credential through and relies on RBAC alone (`/me` reports `credential.legacy_unscoped: true`). Only API keys that *declared* scopes are scope-enforced.
>
> **OAuth runtime connector tokens are the exception — they are scope-strict.** A token obtained through the OAuth connector flow (authenticated by the REST OAuth resource scheme; it carries a `grant_id`) is **not** grandfathered: a connector always mints a scoped token, so a connector token that arrives with **no** scope is rejected **`403 missing_scope`** (fail closed), never waved through. Lenient grandfathering is reserved for the pre-scope API keys it was designed to protect.
>
> (The MCP surface applies its own stricter variant of the scope-less rule — see the scopes section of [MCP server](/public-api/mcp-server/).)
***
## 4. Token lifetime and refresh
[Section titled “4. Token lifetime and refresh”](#4-token-lifetime-and-refresh)
The token lives for **one hour** (`expires_in: 3600`). There is **no refresh token** in the client-credentials flow — when a token nears expiry you simply request a new one with the same credentials.
A robust client **caches the token and re-requests it shortly before it expires**, rather than asking for a new token on every call (which would quickly hit the token endpoint’s strict rate limit — see below). A common pattern: cache the token and its expiry, and fetch a fresh one when fewer than \~30–60 seconds remain.
```ts
// Minimal token cache (TypeScript / fetch)
let cache: { token: string; expiresAt: number } | null = null;
async function getToken(baseUrl: string, clientId: string, clientSecret: string): Promise {
const now = Date.now();
if (cache && cache.expiresAt > now + 30_000) return cache.token; // still valid for >30s
const basic = btoa(`${clientId}:${clientSecret}`);
const res = await fetch(`${baseUrl}/api/v1/auth/token`, {
method: "POST",
headers: {
"Authorization": `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({ grant_type: "client_credentials" }),
});
if (!res.ok) throw new Error(`Madoo auth failed: ${res.status} ${await res.text()}`);
const json = await res.json();
cache = { token: json.access_token, expiresAt: now + json.expires_in * 1000 };
return cache.token;
}
```
> **Token endpoint rate limit.** The `/api/v1/auth/token` endpoint is rate-limited far more strictly than the rest of the API (in production, only a handful of requests per IP within a 15-minute window). This is deliberate — it protects against credential-guessing. Cache your token; do **not** mint a new one per request.
***
## 5. Authentication errors
[Section titled “5. Authentication errors”](#5-authentication-errors)
All errors use the standard Problem Details shape (see [README §6](/public-api/#errors-rfc-7807-problem-details)). The token endpoint follows the OAuth2 error vocabulary in its `code` field:
| HTTP | `code` | Meaning / fix |
| ---- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | `invalid_request` | The `grant_type` is missing, or the Basic header is malformed. Ensure `grant_type=client_credentials` is present. |
| 400 | `unsupported_grant_type` | You sent a `grant_type` other than `client_credentials`. Only client credentials is supported. |
| 401 | `invalid_client` | Credentials are missing, wrong, or the key is revoked/expired. Verify `client_id`/`client_secret` and that the key is active **in this environment**. |
| 401 | `IP_NOT_ALLOWED` | The key has an IP allowlist (`AllowedIPs`) and your request IP is not in it. Returned both by the token endpoint and on authenticated requests. Add your egress IP/range to the key, or clear its allowlist. |
| 403 | `missing_scope` | The key declares capability scopes but not the one this endpoint requires (see [§3.2](#32-capability-scopes)). Re-create the key with the needed scope. Distinct from RBAC’s `403 FORBIDDEN` (missing workspace-role permission). |
A successful integration check-list:
* [ ] Key created in the **same environment** you are calling.
* [ ] `client_secret` copied correctly (no trailing whitespace).
* [ ] Request is `POST`, body is form-encoded, `grant_type=client_credentials` present.
* [ ] You received an `access_token` and are sending it as `Authorization: Bearer …`.
***
## 6. OAuth connector mode (zero-paste, for native app connectors)
[Section titled “6. OAuth connector mode (zero-paste, for native app connectors)”](#6-oauth-connector-mode-zero-paste-for-native-app-connectors)
The second authentication mode is **OAuth 2.0 Authorization Code + PKCE**, for a connector that acts on a **user’s** behalf rather than holding a workspace’s own API key. A user authorizes the connector once, in a browser; the connector then calls `/api/v1` with rotating tokens and never asks the user to paste a Madoo secret — that is why it is the **preferred mode for a native app connector / gateway**.
This mode is **not self-service**: production connector clients are **pre-registered** by Madoo as **confidential** OAuth clients (a `client_id` + a `client_secret`, an exact redirect URI, and the REST runtime scopes). Madoo shares the onboarding procedure, and the reasons behind the refresh-token lifetime, with each connector partner.
### Discovery
[Section titled “Discovery”](#discovery)
A connector bootstraps from standard discovery documents — no hard-coded endpoints:
* **Protected-resource metadata** for the REST API: `GET /.well-known/oauth-protected-resource/api-v1` (RFC 9728) — it points at the authorization server and lists the supported scopes. A `401` from `/api/v1` with no/expired OAuth token also advertises it via the `WWW-Authenticate: Bearer resource_metadata="…"` header.
* **Authorization-server metadata** at the authorization server’s `/.well-known/oauth-authorization-server` — it carries the `authorization_endpoint`, `token_endpoint`, and `revocation_endpoint`.
### The flow (authorize → consent → token → refresh → revoke)
[Section titled “The flow (authorize → consent → token → refresh → revoke)”](#the-flow-authorize--consent--token--refresh--revoke)
1. **Authorize** — redirect the user to the authorization server’s `authorization_endpoint` with `response_type=code`, your `client_id`, the exact `redirect_uri`, a PKCE `code_challenge` (`code_challenge_method=S256`), and the scopes you need (or none — see the default bundle below).
2. **Consent** — the user signs in, **picks the organization + workspace** the connector will act in, and approves the requested capabilities. The audience is fixed **server-side** to the REST API (`/api/v1`) from the client’s profile — the connector does **not** need to send an RFC 8707 `resource` parameter (it may, but only the REST resource is accepted; asking for the MCP resource is rejected `invalid_target`). A Madoo token is **never** valid on both `/api/v1` and `/mcp`.
3. **Token** — exchange the `code` + PKCE `code_verifier` at the `token_endpoint` for an `access_token` (short-lived, REST-audience JWT) and a rotating `refresh_token`. Authenticate the client with `client_secret_basic` (preferred) or `client_secret_post`.
4. **Call** — send `Authorization: Bearer ` to `/api/v1`, exactly like the API-key mode. The access token carries the compact platform claims (`uid`, `org_id`, `ws_id`, `org_role`, `ws_role`, `grant_id`, `scope`).
5. **Refresh** — when the access token nears expiry, exchange the `refresh_token` at the `token_endpoint` for a new pair. **Rotation + reuse detection:** each refresh invalidates the previous refresh token; replaying a consumed one revokes the whole chain. The connector’s refresh token has a longer, per-client lifetime than an interactive session (see the onboarding doc for the ceiling + rationale).
6. **Revoke** — the user can disconnect the app at any time (Madoo “connected apps”); the connector can also call the `revocation_endpoint`. Revocation is **immediate** — the very next `/api/v1` call is rejected `401 grant_revoked`, without waiting for the access token to expire.
### Scopes
[Section titled “Scopes”](#scopes)
OAuth connector tokens are **scope-strict**: a connector always mints a scoped token, and a token that arrives with **no** scope is rejected `403 missing_scope` (unlike a legacy scope-less API key, which is grandfathered — see [§3.3](#33-two-gates-rbac-permissions-vs-capability-scopes)). If the connector requests no scopes, consent grants the **runtime default bundle**:
`catalog:read`, `workflows:read`, `workflows:execute`, `executions:read`, `assets:read`, `assets:write` — note **no `workflows:write`** (a runtime connector executes and reads; it does not author workflows). The full scope vocabulary and per-endpoint requirements are in [§3.2](#32-capability-scopes).
### Supported connector modes (capability summary)
[Section titled “Supported connector modes (capability summary)”](#supported-connector-modes-capability-summary)
| Mode | Mechanism | When |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| **Native connector (preferred)** | OAuth 2.0 Authorization Code + PKCE, confidential pre-registered client, rotating refresh token, REST-audience access token | A gateway / app acting on a user’s behalf — zero-paste |
| **API key (fallback)** | `client_credentials` via `POST /api/v1/auth/token` (§1–§5) | “Any API” / server-to-server / manual secret-based setups |
**Not supported:** implicit grant, password grant, long-lived bearer access tokens without refresh, open/self-service dynamic client registration for production gateways, and any single token valid on both `/api/v1` and `/mcp`.
> **OpenAPI.** Where the OAuth connector surface is enabled for an environment, the Public API OpenAPI document advertises **both** security schemes — the API-key `Bearer` and the `OAuth2` authorization-code flow (with the runtime scopes) — so tooling and the interactive `/docs` reference show both modes.
***
**Next:** [02-quickstart.md](/public-api/quickstart/) — put the token to work and produce your first output end-to-end.
# Workflow authoring (create, update, validate, lifecycle — headless)
Every chapter so far treats workflows as something *someone else built in the editor*. This chapter is about building them **through the API**: creating a workflow from a JSON definition, reading a definition back, replacing it, validating it before you commit, estimating its cost, managing its metadata, and driving the full lifecycle (publish, archive, revert, clone) — all without ever opening the visual editor.
The center of gravity is the **public definition schema `1.0`**: a portable JSON document that describes a workflow’s graph using the human-readable catalog keys from [09-catalog.md](/public-api/catalog/) (node type `"ai/lifestyle"`, model `"flux-2-dev-edit"`, preset `"minimalist"`) instead of internal numeric IDs. The same document shape is what you send to create, what you get back when you read, and what you submit to update — so a definition is also the natural **export/import format** between workspaces and environments.
> **Permissions.** Reads (`GET /workflows`, `GET /{id}`, `GET /{id}/definition`, `GET /{id}/versions`) require the `ws:workflows:read` permission; writes (create, update, validate, estimate, delete, and the lifecycle operations) require `ws:workflows:manage`. Choose the level when you create the API key.
***
## 1. The definition document (schema `1.0`)
[Section titled “1. The definition document (schema 1.0)”](#1-the-definition-document-schema-10)
A complete example — a product photo and an optional style reference go into an AI lifestyle node, which feeds an output node. Port names (`product_image`, `reference_image`, `result`) and parameter names (`mood`, `aspect_ratio`, `custom_prompt`) are exactly what the catalog declares for these node types — always read `GET /api/v1/node-types/{category}/{name}` before wiring:
```jsonc
{
"schema_version": "1.0",
"nodes": [
{ "id": "product_photo", "type": "input/image", "label": "Product Photo" },
{ "id": "style_ref", "type": "input/image", "label": "Style Reference",
"parameters": { "required": false } }, // optional workflow input
{ "id": "hero", "type": "ai/lifestyle", "label": "Hero Image",
"parameters": {
"mood": "minimalist", // preset, by catalog code
"aspect_ratio": "16_9", // preset, by catalog code
"custom_prompt": "soft morning light, editorial studio look"
},
"model": {
"model": "flux-2-dev-edit", // model, by public slug
"params": { "num_inference_steps": 28 }
} },
{ "id": "hero_out", "type": "output/image", "label": "Hero" }
],
"connections": [
{ "from_node": "product_photo", "from_output": "image", "to_node": "hero", "to_input": "product_image" },
{ "from_node": "style_ref", "from_output": "image", "to_node": "hero", "to_input": "reference_image" },
{ "from_node": "hero", "from_output": "result", "to_node": "hero_out", "to_input": "image" }
],
"layout": { // OPTIONAL — see §1.5
"nodes": {
"product_photo": { "x": 0, "y": 0 },
"hero": { "x": 640, "y": 120, "size_preset": "lg" }
}
}
}
```
### 1.1 Nodes
[Section titled “1.1 Nodes”](#11-nodes)
| Field | Meaning |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | **You choose it.** Unique within the workflow, pattern `^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`. Pick readable slugs (`hero`, `brand_brief`); editor-generated GUIDs are also accepted, so round-trips never fail. |
| `type` | A node type code from the catalog, always `category/name` (`GET /api/v1/node-types`). |
| `label` | Optional display label. For input/output nodes it also drives the derived interface port names (see [03-workflows.md §3](/public-api/workflows/#3-the-default-interface)). |
| `parameters` | Values for the parameters the node type declares (see §1.2). |
| `disabled` | Optional; a disabled node is skipped at execution. |
| `model` | Optional AI model override (see §1.3). Only meaningful on AI nodes. |
### 1.2 Parameters: presets, effects, and everything else
[Section titled “1.2 Parameters: presets, effects, and everything else”](#12-parameters-presets-effects-and-everything-else)
Each node type declares its parameters (name, type, constraints) — read them with `GET /api/v1/node-types/{category}/{name}`. Three families matter:
* **Preset parameters** take a preset **code** from the parameter’s category: `"mood": "minimalist"`. Valid codes: `GET /api/v1/presets?category=mood`.
* **Effect parameters** take an effect **code**, either plain (`"effect": "foil_gold"`) or with an intensity: `"effect": { "effect": "foil_gold", "intensity": 60 }` (integer; meaningful only for effects with `supports_intensity: true`). Valid codes: `GET /api/v1/effects`.
* **Everything else** (`text`, `number`, `boolean`, `enum`, …) is passed as a plain JSON value.
**Nodes that fill a document template** — `design/template_render`, `document/pdf` and `aggregate/pdf` — select it with **`template_id`** (a `tpl_…` from [`GET /api/v1/design-templates`](/public-api/design-templates/)) and an optional **`template_revision`**, never with `documentId` or the other internal document fields the editor stores (rejected with `internal_template_parameter`). Their input ports are the template’s **field codes**, typed like the fields (text, number, boolean, JSON list for a Repeat, image); read them from the template’s contract, not from the node type. `design/template_render` always fixes a published revision (the current one when `template_revision` is omitted). `document/pdf` and `aggregate/pdf` are not pinned: without `template_revision` they render the template’s **current draft** at run time — set it for a reproducible workflow. In `document/pdf` and `aggregate/pdf` an unconnected field renders the template’s default value, and a field the template marks **required** with no default must be connected, or the definition is rejected with `template_field_not_connected` (every run would fail). `design/template_render` can also receive a field through its `data` objects, so it checks required fields when it renders (`missing_policy`). Reading a definition back returns the same portable form, also for nodes configured in the editor.
**Unknown parameters are not errors.** A parameter name the node type does not declare is **discarded with a warning** (code `unknown_parameter` in `meta.warnings`). This keeps your definitions tolerant to catalog evolution — but check the warnings: a typo in a parameter name means the value silently does nothing.
### 1.3 The model override
[Section titled “1.3 The model override”](#13-the-model-override)
```jsonc
"model": {
"model": "flux-2-dev-edit", // public slug; omit to use the workspace/system default
"params": { "guidance_scale": 3.5 }, // model parameters (GET /models/{model}/params-schema)
"capability_models": { // for multi-capability nodes: per-capability override
"outpaint": "flux-2-dev-edit"
}
}
```
Omit `model` entirely (or just `model.model`) and the node uses the **effective default** for your workspace — the same one reported by `GET /api/v1/node-types/{category}/{name}/models`. The model must support one of the node type’s capabilities, or the write is rejected with `model_not_allowed_for_capability`.
**`params` require an explicit `model`.** Model parameters are validated against the declared schema of the model you name (`GET /models/{model}/params-schema`): unknown keys are dropped with a warning (`unknown_model_parameter`). If you send `params` without naming a model — or the model declares no parameter schema — the **whole `params` bag is discarded** with a `model_params_schema_unavailable` warning: nothing undeclared is ever persisted or forwarded to a provider. The same filter applies on export: `GET /definition` only emits declared parameters.
### 1.4 Connections
[Section titled “1.4 Connections”](#14-connections)
Flat list; every entry names the source node + output port and the target node + input port. Port names come from the node type’s `inputs[]`/`outputs[]` in the catalog. All four fields are required; a reference to a missing node or port is a blocking error (`unknown_node`, `unknown_port`).
#### Compact array inputs
[Section titled “Compact array inputs”](#compact-array-inputs)
When an array input declares `"compact": true`, connect individual producers through indexed input names. This example combines three portable checks with `utility/quality_gate`:
```jsonc
{
"nodes": [
{ "id": "image_check", "type": "media/technical_info" },
{ "id": "audio_check", "type": "media/technical_info" },
{ "id": "video_check", "type": "media/technical_info" },
{ "id": "gate", "type": "utility/quality_gate",
"parameters": { "profile": "all_must_pass", "mode": "route" } }
],
"connections": [
{ "from_node": "image_check", "from_output": "quality_check", "to_node": "gate", "to_input": "checks_0" },
{ "from_node": "audio_check", "from_output": "quality_check", "to_node": "gate", "to_input": "checks_1" },
{ "from_node": "video_check", "from_output": "quality_check", "to_node": "gate", "to_input": "checks_2" }
]
}
```
Indices must be unique and below `max_count`. The editor keeps them contiguous when connections are removed. Headless authors should do the same for predictable round-trips. If an upstream node already returns a `json[]`, connect it once to the base name (`checks`) instead; the indexed and base-array forms cannot be mixed.
`utility/quality_gate` combines evidence; it does not inspect media or call AI. `route` completes and exposes the real `passed` verdict, `warn` completes while preserving a false verdict and report, and `fail` fails the gate node so no dependent delivery node runs. Because Madoo preserves work already completed, a workflow stopped by a downstream fail-fast gate may finish as `partial_success`; use the absence of expected delivery outputs and the node diagnostics to distinguish this from an accepted delivery.
#### Audio signal quality
[Section titled “Audio signal quality”](#audio-signal-quality)
`audio/quality_report` measures an authorized storage-backed audio asset without modifying it or calling a provider. Profiles provide transparent baseline thresholds and any explicit numeric parameter overrides the profile value. The node exposes both the detailed measurements and a portable check:
```jsonc
{
"nodes": [
{ "id": "voice", "type": "input/audio" },
{ "id": "audio_quality", "type": "audio/quality_report",
"parameters": {
"profile": "delivery",
"check_id": "voice-delivery",
"target_lufs": -16,
"maximum_true_peak_dbtp": -1.5,
"fail_on_violation": false
} },
{ "id": "gate", "type": "utility/quality_gate",
"parameters": { "profile": "all_must_pass", "mode": "route" } }
],
"connections": [
{ "from_node": "voice", "from_output": "audio", "to_node": "audio_quality", "to_input": "audio" },
{ "from_node": "audio_quality", "from_output": "quality_check", "to_node": "gate", "to_input": "checks_0" }
]
}
```
LUFS measures perceived programme loudness; dBTP describes reconstructed true peaks; dBFS is used for the decoded sample threshold. `clippedSampleCount` is the actual number of decoded samples at or above `clipping_threshold_dbfs`, not a boolean inferred from the container. Internal silent regions are reported as dropout *candidates*: the node does not claim to know whether an intentional pause is a defect.
`fail_on_violation=false` is the composable default: the node completes with `passed=false` and retains all evidence. Set it to `true` only when this check itself must stop dependent nodes. The operation is local and has zero provider-credit cost, but it still participates in the normal execution, retry and settlement flow.
#### Measured loudness normalization
[Section titled “Measured loudness normalization”](#measured-loudness-normalization)
`audio/loudness_normalize` creates a new audio asset at a consistent delivery loudness. It is not an alias for `audio/volume`: in the recommended `two_pass` mode it first measures the complete programme using the node’s actual LUFS, true-peak and loudness-range targets, then feeds those measurements into the second EBU R128 pass. Madoo probes and measures the produced file again before publishing it.
```jsonc
{
"id": "normalize_voice",
"type": "audio/loudness_normalize",
"parameters": {
"profile": "podcast",
"check_id": "podcast-delivery",
"mode": "two_pass",
"output_format": "wav",
"fail_on_target_miss": true
}
}
```
Profiles are visible defaults, not hidden processing modes: `podcast` starts at -16 LUFS/-1.5 dBTP, `broadcast` at -23 LUFS/-1 dBTP and `social` at -14 LUFS/-1 dBTP. Explicit `target_lufs`, `true_peak_dbtp`, `loudness_range_lu` and `loudness_tolerance_lu` values override them. `single_pass` is available only when selected explicitly; it is never a silent fallback when two-pass measurement fails. The editor labels inherited numeric values as profile defaults and lets the author clear an explicit override to return to the selected profile without persisting a duplicate value.
Across the Audio Finishing pack, `advanced=true` is the canonical authoring hierarchy rather than an Editor-only convention. REST, MCP, Agent and Assistant clients should place stable check IDs and technical profile overrides behind an optional fine-tuning surface. Omitting an override preserves inheritance; it must not be serialized as the parameter’s numeric minimum.
With `fail_on_target_miss=true`, an output outside the measured tolerance is not committed. Set it to `false` when the workflow must retain and route the failed `quality_check`. Local normalization uses zero provider credits while retaining normal storage admission, retries and settlement behaviour.
#### Measured audio duration fitting
[Section titled “Measured audio duration fitting”](#measured-audio-duration-fitting)
`audio/fit_duration` fits one storage-backed clip to one explicit time slot. A connected `target_duration` number input, expressed in seconds, overrides the saved parameter. The required tempo rate is `original duration / target duration`; Madoo compares it with `minimum_rate` and `maximum_rate` before processing.
```jsonc
{
"id": "fit_localized_cue",
"type": "audio/fit_duration",
"parameters": {
"target_duration": 4.3,
"check_id": "localized-cue-duration",
"strategy": "tempo_then_pad",
"minimum_rate": 0.85,
"maximum_rate": 1.2,
"preserve_pitch": true,
"preserve_formants": true,
"pad_position": "end",
"tolerance_ms": 80,
"on_out_of_range": "fail",
"output_format": "wav"
}
}
```
`tempo` performs only time stretching. `tempo_then_pad` may add declared silence when a clamped result is short; `tempo_then_trim` may remove the tail when it is long. With an out-of-range required rate, `fail` stops before processing, `clamp` applies the nearest bound and lets the selected correction strategy act, and `warn` applies the requested rate while emitting an explicit warning. The produced file is probed again: `actual_duration`, `fit_report`, and `quality_check` always describe measured output rather than an assumed filter result. Rubber Band is preferred for pitch/formant preservation; `atempo` is a declared fallback. This local operation uses zero provider credits.
#### Provider-neutral TTS cues
[Section titled “Provider-neutral TTS cues”](#provider-neutral-tts-cues)
`ai/text_to_speech` produces one speech clip and a `generation_metadata` receipt conforming to `madoo.tts-generation/v1`. Its required `text` can be authored or connected from the current translated cue. Optional input ports `language`, `voice`, `speaking_rate`, `style`, `seed`, and `reference_audio` are per-cue overrides: a connected value wins over the matching `model_config.model_params` value. The selected model must advertise the control; otherwise execution stops before the paid provider call with `TTS_PARAMETER_UNSUPPORTED`. F5 and Index TTS require `reference_audio`; ElevenLabs exposes the main Italian-dubbing controls without a reference clip.
Use `GET /api/v1/models/{model}/params-schema` for static model controls and `GET /api/v1/node-types/ai/text_to_speech` for the dynamic ports. The receipt records model/provider, effective non-secret parameters, provider-returned duration/sample rate when available, and generation time/RTF when measurable.
#### Named stem separation
[Section titled “Named stem separation”](#named-stem-separation)
`ai/stem_separation` separates a mixed recording into four mandatory audio outputs: `vocals`, `drums`, `bass`, and `other`. These are semantic ports, not positional variants. A provider response missing any stem fails explicitly with `STEM_OUTPUT_INCOMPLETE`; it is never published as a partial success. Preserve the `stems_manifest` receipt (`madoo.stem-separation/v1`) for the model, algorithm and effective controls.
For dubbing, use `vocals` as the dialogue reference and reconstruct the complete background through explicit `audio/mix` nodes over `drums + bass + other`. The `other` output contains remaining instruments, not the whole residual background. The Demucs baseline is billed from Madoo’s measured input duration and accepts at most 90 seconds in one execution; split long-form recordings into bounded segments and restore them on their original timeline. Use `ai/audio_isolation` when only a cleaned voice is required and background recovery is not needed.
#### Absolute dubbing timeline aggregation
[Section titled “Absolute dubbing timeline aggregation”](#absolute-dubbing-timeline-aggregation)
`aggregate/audio_timeline` closes the per-cue TTS branch and restores speech on the absolute master timeline. Connect its required `clips`, `cue_id`, `start`, and `end` inputs to aligned iteration outputs from the same enumeration root; `speaker` is optional and must remain aligned when used. The clip may come directly from TTS or from `audio/fit_duration`. Pass the measured master duration in seconds to the optional `timeline_duration` input when available; it overrides the saved fallback parameter.
```jsonc
{
"id": "localized_speech_timeline",
"type": "aggregate/audio_timeline",
"parameters": {
"timeline_duration": 3600,
"overlap_policy": "fail",
"missing_cue_policy": "fail",
"gap_fill": "silence",
"output_format": "wav"
}
}
```
The node does not merely concatenate clips: every uncovered interval remains silence, preserving pauses in the source call. `fail` is the safe default for overlaps and missing iterations. `mix` deliberately mixes overlapping speech; `trim_previous` cuts the earlier cue at the next start. `missing_cue_policy=warn` renders the absent interval as silence and records it in both outputs. Rendering is bounded to 16 local inputs per operation and intermediates are concatenated hierarchically, so long-form timelines do not require every clip in memory or on local disk at once. Persist `timeline_manifest` (`madoo.audio-timeline/v1`) for audit and route `quality_check` to `utility/quality_gate`. Use `aggregate/audio_merge` only when pauses should collapse.
For caption-driven fitting, connect each cue’s `start` and `end` directly to `audio/fit_duration.target_start` and `target_end`. This keeps caption timing as the only source of truth; the saved `target_duration` remains a non-caption fallback.
#### Auditable dubbing delivery manifest
[Section titled “Auditable dubbing delivery manifest”](#auditable-dubbing-delivery-manifest)
After timeline rendering, mix or reconstruct the background, normalize loudness, measure the final audio, replace the master video’s audio and probe the produced video. Then add `utility/dubbing_manifest`. Build its `cue_evidence` with one `aggregate/json` row per cue using these exact properties:
```jsonc
{
"cueId": "cue-001",
"generation": { "schema": "madoo.tts-generation/v1" },
"fitReport": { "schema": "madoo.audio-processing-report/v1" }
}
```
Connect the complete source and localized `madoo.caption-track/v1` documents, the cue evidence array, `madoo.audio-timeline/v1`, master/final media facts, final audio measurements and every applicable Quality Check. Set `background_mode` to `provided`, `separated`, or `none`; `separated` also requires the `madoo.stem-separation/v1` receipt. Route the emitted `quality_check` into a blocking Quality Gate and persist `manifest` as JSON.
For reusable dubbing, expose a true-default `input/boolean` such as `preserve_background`, gate the segmented Demucs branch with `utility/filter`, set downstream `audio/duck` `background_absent_behavior=pass_foreground`, and connect the same boolean to `utility/dubbing_manifest.preserve_background`. False skips stem separation, passes the dialogue timeline through without another FFmpeg mix, and records `background_mode=none`. Do not infer that choice from silence, loudness, or transcript gaps: they do not reliably distinguish clean speech from quiet ambience or music under continuous dialogue.
The resulting `madoo.dubbing-manifest/v1` records source/target text, absolute cue timing, effective voice and model, generated/fitted durations, fit state, speaker mapping, stable asset references and processing provenance. It is built from measured evidence and cannot be supplied as free-form AI JSON.
#### Measured sidechain ducking
[Section titled “Measured sidechain ducking”](#measured-sidechain-ducking)
`audio/duck` uses foreground speech as an RMS sidechain detector, attenuates the background, and then mixes both tracks through a peak-safety limiter. Both inputs must be storage-backed audio:
```jsonc
{
"id": "voice_over_mix",
"type": "audio/duck",
"parameters": {
"profile": "voice_over",
"check_id": "voice-over-ducking",
"output_duration": "foreground",
"output_format": "wav",
"measure_result": true
}
}
```
Profiles are transparent baselines: `voice_over`, `subtle`, `aggressive`, or `custom`. Explicit `threshold_db`, `ratio`, `attack_ms`, `release_ms`, `background_gain_db`, and `foreground_gain_db` values override the selected profile. `output_duration` chooses `foreground`, `longest`, or `shortest`. With measurement enabled, the processing report compares the base-gain-adjusted background loudness with the isolated ducked background and reports the observed reduction; the final mix is independently probed and measured before publication. This local operation uses zero provider credits.
#### Local speech enhancement
[Section titled “Local speech enhancement”](#local-speech-enhancement)
`audio/speech_enhance` cleans one storage-backed recording locally before delivery processing. Start with a profile and add only deliberate overrides:
```jsonc
{
"id": "clean_voice",
"type": "audio/speech_enhance",
"parameters": {
"profile": "podcast",
"check_id": "speech-cleanup",
"denoise_technique": "auto",
"preserve_ambience": true,
"output_format": "wav"
}
}
```
The `light`, `podcast`, and `dialogue` profiles progressively combine bandwidth filtering, broadband denoise, bounded click/clip and sibilance repair, gating, and mild dynamics control. `custom` starts from the light baseline. Explicit `denoise`, `declick`, `declip`, `deesser`, `high_pass_hz`, `low_pass_hz`, `gate`, and `compression` values override the chosen profile. Strong gate/declip settings can sound unnatural; `preserve_ambience=true` keeps denoise and gating gentler.
The output is re-probed and measured. `processing_report` declares every actual FFmpeg filter and effective numeric value, while `quality_check` validates duration preservation and peak safety. The report deliberately does not invent an absolute SNR estimate. This node does not remove arbitrary room reverberation and does not separate voice from competing music; use `ai/stem_separation` when both voice and background are needed, or `ai/audio_isolation` when only the cleaned voice is needed, and place `audio/loudness_normalize` after cleanup when a delivery loudness target is required. The local operation uses zero provider credits.
#### Long-form transcription and caption translation
[Section titled “Long-form transcription and caption translation”](#long-form-transcription-and-caption-translation)
Long media uses domain-specific enumerator/aggregator pairs rather than a generic JSON loop:
```text
video/extract_audio → enumerate/audio_segments → ai/speech_to_text → aggregate/transcript
aggregate/transcript.caption_track → enumerate/caption_blocks → ai/caption_translation
→ aggregate/caption_track
```
`aggregate/transcript` needs five aligned connections: Speech to Text `segments`, plus Audio Segments `index`, `segment_id`, `start` and `end`. `aggregate/caption_track` needs both the iterated `translation` output and the original `caption_track` connected directly to `source_track`. A cue is one timed subtitle event: translation changes its text but never its ID or timing. See the complete authoring guide and the runnable `large-media-english-italian-captions.json`.
For synchronized natural dubbing request Speech to Text `granularity=word` with a word-capable model such as ElevenLabs Scribe V2 or Whisper; Wizper is segment-only. Enable `aggregate/transcript.speech_ready`. It groups observed word boundaries into short readable phrases without crossing speaker changes. After natural-speech finalization keep `delivery_captions` for technical-slot and manifest evidence, but connect `speech_aligned_captions` to `video/subtitles`; end padding is excluded from the subtitle display interval.
For a reusable localization workflow, expose the target BCP 47 tag with `input/text` and connect it to every `ai/caption_translation.target_language`; a connected language overrides the saved parameter. Expose runtime choices with `input/boolean` and route its strict boolean output through `utility/filter` to gate optional branches. For natural speech, connect the measured `media/technical_info.duration` in seconds to both `utility/speech_timing_plan.duration` and `aggregate/audio_timeline.timeline_duration`, rather than hard-coding the source length.
For nested composition, a `workflow/sub` node may call a workflow that contains further `workflow/sub` nodes. The complete execution chain must remain acyclic and is limited to five levels. Use `input/json_value` when a complete caption track, timing state, quality check, plan, manifest or other JSON document crosses the child boundary. It preserves exactly one scalar workflow value, including an array root, and never creates fan-out. Do not use `input/json` or `enumerate/json` for this purpose: both are iteration sources. Child outputs are bridged only after the child completes, so keep independent expensive phases as parallel sibling sub-workflows rather than accidentally serializing them.
When a consumer in a later phase must authenticate evidence persisted by an earlier child execution, place `utility/execution_reference` in the producing workflow. Expose its server-minted `execution_id` through an `output/text` boundary and receive it through `input/text` in the consuming workflow, alongside the typed evidence document. The `run_` ID is a correlation reference, not a credential: the consumer still validates tenant ownership, terminal state and the evidence itself. Do not hard-code a previous run ID into a reusable workflow definition.
For deliver-first translation, set the first `ai/caption_translation.failure_policy` to `best_effort`. `aggregate/caption_track` then emits the usable track plus `pending_captions`, containing only cues that still use source-text fallback, `pending_count`, and `complete`. Add one bounded repair corridor: `pending_captions` → `enumerate/caption_blocks` (small blocks, normally at most four cues) → `ai/caption_translation` → a second `aggregate/caption_track`. Connect the original source track to the second aggregator’s `source_track`, the first translated track to `baseline_track`, and the repair iterations to `translations`. If the first pass is complete, `pending_captions` is Absent and the paid repair corridor skips automatically. Only the second track feeds speech timing and TTS. After that one round, any remaining fallback is delivered with `complete=false` and explicit warnings instead of repeating STT, the full translation, or already valid TTS.
To expose the finished tracks in one player without transcoding the master, add `video/caption_tracks`. Its required `tracksConfig` is a step-shape parameter: each row declares a stable dynamic input/output port, BCP 47 language, viewer label, `captions|subtitles` meaning, `auto|caption_track|srt|vtt` input format and whether it is the single default track. Fetch the node’s full catalog detail before authoring; connect the original video plus one timed source per configured row, then connect `bundle` to `output/json`. Madoo stores only tenant-relative paths in the manifest and signs every referenced asset when an authorized viewer opens it.
#### Long-video social highlights
[Section titled “Long-video social highlights”](#long-video-social-highlights)
Treat highlight extraction as a typed, bounded edit pipeline. Expose the requested final length with `input/number` (an authored `value` of `60` is the recommended default), inspect the master once, and fan it out with `enumerate/video_segments`. Wire each segment plus its `segment_id`, `index`, `start` and `end` to `ai/video_highlight_analysis`; `failure_policy=best_effort` lets one failed analysis yield an empty typed candidate set instead of losing a long-running production.
Connect the iterated `candidates` directly to `aggregate/video_highlight_plan`, together with the measured master duration and numeric target. The planner—not the provider—validates ranges, removes overlaps, respects the duration ceiling and restores chronological order. If a complete `madoo.caption-track/v1` is available, connect it too so cuts can snap near speech boundaries. For spoken content, request Speech to Text `granularity=word` and aggregate the same result twice: use `speech_ready=false` as the planner’s technical word-boundary track, and `speech_ready=true` as the readable caption track used for subtitle projection. The planner then prefers sentence endings, reliable speaker changes and strong pauses, allowing only the configured small snap margin around a per-clip duration cap. A short breath after a comma or other continuation punctuation is not a clean ending; if an AI proposal stops inside an unfinished thought, the planner can backtrack to a recent complete sentence. After a complete sentence it may retain up to 0.4 seconds of observed room tone, stopping before the next spoken word; this keeps a hard cut from landing on the last phoneme. Render the selected clips by sending the original master and typed plan to `video/edit_timeline`; its measured `madoo.video-edit-manifest/v1` is the authoritative source-to-output time map for `utility/caption_timeline_project`.
For optional burned-in subtitles, expose `input/boolean` and use `utility/filter` twice: first to gate the source media before extraction/transcription, then to gate the projected caption track before `video/subtitles`. The false path therefore spends no Speech-to-Text credits and still returns the base highlight video; the trade-off is that planning cannot snap cuts to caption boundaries. Persist the highlight plan, edit manifest and quality reports as reviewable outputs. For single-subject social edits use `speaker_labels=hide`. In general `auto` shows multiple speakers only when their identities are global and reliable; labels scoped to independently transcribed segments are hidden, because `speaker_0` in one segment cannot be assumed to identify the same person in another. Forced labels are humanized (`speaker_0` becomes `Speaker 1`) in both burned-in captions and sidecars. V1 deliberately uses hard cuts, because transitions need explicit timing semantics before audio, video and projected captions can remain frame-aligned. See Video Highlights and the runnable `highlights` demo.
### 1.5 Layout is optional — and faithfully round-tripped
[Section titled “1.5 Layout is optional — and faithfully round-tripped”](#15-layout-is-optional--and-faithfully-round-tripped)
The `layout` section carries the **visual** placement (positions, sizes, collapsed state) keyed by node id, plus per-interface editor layouts under `layout.interfaces`. It is never required:
| Scenario | Behaviour |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| Create **without** `layout` | Works. Madoo assigns default grid positions (one column per topological level), so the workflow opens cleanly in the editor. |
| `GET /definition` of an editor-built workflow | Full `layout` returned — the export is faithful. |
| `PUT /definition` **without** `layout` | The stored layout is **preserved** for nodes that survive the update; new nodes get default positions. |
| `PUT /definition` **with** `layout` | Taken as-is (entries for unknown node ids are dropped with a warning). |
Semantics never depend on layout: two definitions that differ only in `layout` run identically.
### 1.6 Custom interfaces
[Section titled “1.6 Custom interfaces”](#16-custom-interfaces)
The optional `interfaces[]` section defines [custom interfaces](/public-api/workflows/#5-custom-interfaces) in the same shape the read API exposes, plus the mapping that connects each field to a node input or parameter (`mapping.target_type`, `mapping.node_id`, `mapping.parameter_key`). Invalid mappings are blocking errors (`invalid_interface`). Note that custom interfaces may be gated by your plan.
### 1.7 Limits
[Section titled “1.7 Limits”](#17-limits)
| Limit | Value | On violation |
| -------------------------- | ----- | ------------------------------ |
| Nodes per definition | 200 | `422` (`too_many_nodes`) |
| Connections per definition | 1000 | `422` (`too_many_connections`) |
| Request body size | 1 MB | `413` |
***
## 2. Creating a workflow
[Section titled “2. Creating a workflow”](#2-creating-a-workflow)
```plaintext
POST {BASE_URL}/api/v1/workflows
```
```jsonc
{
"name": "Newsletter Hero", // required
"description": "Hero image pipeline", // optional
"tags": ["newsletter"], // optional
"definition": { /* schema 1.0 — required */ }
}
```
One call, one workflow — there is no “empty shell then fill it” step. Semantics:
* **Atomic.** Any blocking error → `422` (`validation_error`) with the full `errors[]` list, and **nothing is created**. All errors are collected in one pass, not just the first.
* **Warnings don’t block.** The workflow is created and the warnings are echoed in `meta.warnings[]`.
* **Always a draft.** Publishing is an explicit, separate step (`POST /{id}/publish` — see §7).
* **Plan limits** apply: exceeding your plan’s workflow cap returns `402` (`plan_limit_exceeded`).
### Safe retries: the `Idempotency-Key` header
[Section titled “Safe retries: the Idempotency-Key header”](#safe-retries-the-idempotency-key-header)
Network timeouts make “did my create go through?” a real question. Send an optional **`Idempotency-Key`** header (any string up to 255 chars, e.g. a UUID or your job ID) and retries become safe: the same key, within the same workspace, always resolves to the same workflow. The first call creates it; a retry with the **same payload** returns the **already-created** workflow with `201` and the response header `Idempotency-Replayed: true`, instead of a duplicate. The replay is decided before validation, so a true retry replays even if the catalog changed in between; concurrent duplicates (two in-flight requests with the same key) are also safe — one creates, the other replays it.
```bash
curl -s -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: import-job-7f3a" \
-d @workflow.json "$BASE_URL/api/v1/workflows"
```
Reusing a key with a **different payload** is rejected with `409` (`idempotency_conflict`) — it is almost always a bug in the caller. One key, one logical create; use a new key for a new workflow.
A successful create returns `201` with the same shape as `GET /workflows/{id}` — including the **derived interface**, so you can immediately see the I/O contract your input/output nodes produced — plus the authoring envelope:
```jsonc
{
"id": "wf_9c1d…",
"name": "Newsletter Hero",
"status": "draft",
"version": 1,
"interface": { "inputs": [ /* … */ ], "outputs": [ /* … */ ] },
"meta": {
"warnings": [
{ "code": "unknown_parameter", "message": "Parameter 'colour' is not declared by node type 'ai/lifestyle' and was discarded.",
"node_id": "hero", "parameter": "colour", "path": "$.nodes[2].parameters.colour",
"suggestion": "See the declared parameters with GET /api/v1/node-types/ai/lifestyle." }
],
"migrations": [] // reserved; always empty in V1
}
}
```
### The error/warning shape
[Section titled “The error/warning shape”](#the-errorwarning-shape)
Every issue — blocking or not — carries structured locators **and** a JSONPath into the document you submitted, plus an actionable suggestion when there is one:
```jsonc
{ "code": "unknown_model",
"message": "Model 'flux-99' does not exist or is not enabled.",
"node_id": "hero", "parameter": null,
"path": "$.nodes[2].model.model",
"suggestion": "List the selectable models with GET /api/v1/node-types/ai/lifestyle/models." }
```
| Blocking errors (`422`, in `errors[]`) | Non-blocking warnings (in `meta.warnings[]` / `warnings[]`) |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `unsupported_schema_version` | `unknown_parameter` (dropped) |
| `too_many_nodes`, `too_many_connections` | `unknown_model_parameter` (dropped) |
| `invalid_node_id`, `duplicate_node_id` | `model_params_schema_unavailable` (whole `params` bag dropped) |
| `unknown_node_type` | `model_ignored` (model on a non-AI node, dropped) |
| `unknown_preset`, `unknown_effect`, `invalid_parameter_value` | `unknown_layout_node`, `unknown_layout_interface` (dropped) |
| `unknown_model`, `model_not_allowed_for_capability`, `unknown_capability` | `invalid_interface_layout` (dropped) |
| `unknown_node`, `unknown_port`, `invalid_connection` | `disconnected_node`, `no_output_nodes`, `unused_input_node`, `dead_end_branch` |
| `missing_template_id`, `invalid_template_id`, `invalid_template_revision`, `template_not_found`, `template_revision_not_found`, `template_pin_failed`, `internal_template_parameter`, `template_field_not_connected` (template nodes — see [08 §3](/public-api/reference/#3-error-code-catalogue)) | |
| `connection_type_mismatch` (port data types incompatible — same rules the visual editor enforces: equal types, `any`, single output into an array slot, `pdf`↔`document`) | |
| `cycle_detected` | `unresolved_reference` (export only — see §4) |
| `invalid_interface`, `duplicate_interface_id`, `multiple_default_interfaces` | `text_template_placeholder_unconnected`, `unsupported_inline_node_reference` |
***
## 3. Validating without saving
[Section titled “3. Validating without saving”](#3-validating-without-saving)
```plaintext
POST {BASE_URL}/api/v1/workflows/{id}/validate
```
Two modes:
* **With a body** `{ "definition": { … } }` — validates *that* document (full catalog resolution + graph checks) without touching the stored one. The pre-flight you run before a `PUT`.
* **Without a body** — validates the workflow’s **stored** definition.
Validation problems are the *payload* here, not an error status: the response is always `200` with `valid`, `authoring_ready`, the same `errors[]`/`warnings[]` shape as above, and a graph summary. `valid` means the graph is structurally executable. `authoring_ready` is the stronger completion gate for automated authors: it is false for high-confidence semantic problems such as unused founder inputs, dead branches, unbound template placeholders or invented inline node references. Human clients may still save an incremental Draft while this value is false; agents should repair and re-validate until both values are true.
```jsonc
{
"valid": false,
"authoring_ready": false,
"errors": [ { "code": "cycle_detected", "message": "…" } ],
"warnings": [ { "code": "disconnected_node", "node_id": "stray", "message": "…" } ],
"summary": {
"total_nodes": 5, "total_connections": 4,
"input_nodes": 2, "output_nodes": 1,
"has_cycles": true,
"node_types_used": ["input/text", "ai/lifestyle", "output/image"],
"disconnected_nodes": ["stray"]
}
}
```
***
## 4. Reading a definition
[Section titled “4. Reading a definition”](#4-reading-a-definition)
```plaintext
GET {BASE_URL}/api/v1/workflows/{id}/definition
GET {BASE_URL}/api/v1/workflows/{id}/definition?version=3
```
Returns the definition in the public schema, wrapped with the version it belongs to:
```jsonc
{ "workflow_version": 4, "definition": { "schema_version": "1.0", /* … */ } }
```
Historical versions are immutable — pinning `?version=` always returns the same document. The export is **faithful**: an editor-built workflow comes back with its full layout, GUID node ids and all, and can be re-submitted as-is. That makes `GET /definition` → `POST /workflows` the supported way to copy a workflow across workspaces or environments (the definition is the portable format; there is no separate export/import endpoint pair in the public API).
The response also carries an **`ETag` header** — the fingerprint of the definition content. Hold on to it: it is what you send back in `If-Match` to make your next `PUT` safe against concurrent edits (see §5). (The header is a concurrency token for `PUT`, not a caching tag: conditional GETs with `If-None-Match` are not processed on this endpoint.)
**Unresolvable references degrade safely.** If a stored definition references catalog entries that no longer resolve (a deleted preset, a retired model), the export **omits** those values — internal identifiers are never emitted in their place — and reports each omission in a `warnings[]` field (code `unresolved_reference`). Check it before re-importing an old definition.
**Listing the versions:**
```plaintext
GET {BASE_URL}/api/v1/workflows/{id}/versions
```
```jsonc
{
"workflow_version": 4,
"versions": [
{ "version": 4, "is_current": true },
{ "version": 3, "is_current": false },
{ "version": 2, "is_current": false },
{ "version": 1, "is_current": false }
]
}
```
There is no dedicated “restore” endpoint, because none is needed: **restore = read + write**. `GET /definition?version=2`, then `PUT` that document back — the old content becomes the new current version (a new version number; history is never rewritten).
***
## 5. Updating a definition
[Section titled “5. Updating a definition”](#5-updating-a-definition)
```plaintext
PUT {BASE_URL}/api/v1/workflows/{id}/definition
```
Body: `{ "definition": { … } }`. Full replacement — there is no partial patch of a definition. Same atomic `422` envelope as create; on success you get `200` with the updated resource and `meta.warnings`, exactly like create.
Two behaviours to know:
* **Versioning is copy-on-write.** Updating a *published* workflow (or a draft whose current version is referenced by past executions) increments `version` and writes a new immutable blob; executions pinned to older versions are unaffected.
* **Concurrency: use `If-Match`.** Without it, the later save silently wins (last-write-wins).
### Optimistic concurrency with `ETag` / `If-Match`
[Section titled “Optimistic concurrency with ETag / If-Match”](#optimistic-concurrency-with-etag--if-match)
The scenario the mechanism protects: your integration reads the definition, a colleague saves a change from the editor in the meantime, your `PUT` would silently wipe their work. To prevent it:
1. `GET /definition` returns an **`ETag` header** — a fingerprint of the definition *content*.
2. Send it back on the `PUT` in an **`If-Match`** header.
3. If the stored definition still matches, the write goes through (`200`, with the **new** ETag in the response — chain it into your next edit). If someone changed it in between, you get **`412`** (`precondition_failed`): re-read, re-apply your change, retry.
```bash
ETAG=$(curl -sI -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/api/v1/workflows/$WF/definition" | grep -i '^etag:' | cut -d' ' -f2 | tr -d '\r')
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "If-Match: $ETAG" \
-d @candidate.json "$BASE_URL/api/v1/workflows/$WF/definition"
```
The tag tracks the definition **content only**: renaming the workflow (PATCH), publishing, or a copy-on-write version bump that re-writes identical content do **not** invalidate it — only actual node/connection/layout changes do. The precondition is checked against an authoritative, cache-bypassing read while a per-workflow SQL lock is held through the versioned blob and pointer write, so it holds across server instances and concurrent saves. `If-Match: *` is accepted (means “the workflow exists”); a single tag per header is supported (no RFC multi-tag lists). `If-Match` is optional, but recommended whenever the editor and your integration may touch the same workflow.
(Layout preservation on PUT without `layout`: see §1.5.)
***
## 6. Metadata, deletion, and finding your drafts
[Section titled “6. Metadata, deletion, and finding your drafts”](#6-metadata-deletion-and-finding-your-drafts)
**Metadata** — name, description, tags — changes without touching the definition:
```plaintext
PATCH {BASE_URL}/api/v1/workflows/{id}
```
```jsonc
{ "name": "New name", "tags": ["v2"] } // absent fields are left unchanged
```
**Deletion** is a soft delete:
```plaintext
DELETE {BASE_URL}/api/v1/workflows/{id} → 204
```
If the workflow has executions still running you get `409` (`executions_in_progress`) — cancel or wait for them first. Deleting a *published* workflow is allowed; if consumers may still depend on it, prefer archiving (`POST /{id}/archive`, §7) — it is reversible.
**Listing by status.** The workflow list defaults to published-only (unchanged for existing integrators), but now accepts a filter, and every workflow carries a `status` field:
```plaintext
GET {BASE_URL}/api/v1/workflows?status=draft // draft | published | archived | all
```
`GET /workflows/{id}` likewise resolves drafts and archived workflows (a key with read permission sees the whole workspace surface).
***
## 7. Lifecycle: publish, archive, revert, clone
[Section titled “7. Lifecycle: publish, archive, revert, clone”](#7-lifecycle-publish-archive-revert-clone)
A workflow moves between three statuses — `draft`, `published`, `archived` — and the public API drives every transition. All four endpoints take no meaningful body except clone, and return the updated resource.
```plaintext
POST {BASE_URL}/api/v1/workflows/{id}/publish
POST {BASE_URL}/api/v1/workflows/{id}/archive
POST {BASE_URL}/api/v1/workflows/{id}/revert
POST {BASE_URL}/api/v1/workflows/{id}/clone
```
**Publish** makes the workflow executable. Two things to know:
* **It is gated on a valid definition.** If the stored definition has blocking errors (a cycle, an invalid interface mapping, …), publish is refused with the same `422` `validation_error` envelope as create, and nothing changes. The gate is enforced atomically on the definition snapshot being published (a concurrent invalid save cannot slip through), and it holds platform-wide — the editor’s publish is gated by the same rule. Run `POST /{id}/validate` first if you want to check without attempting.
* **Re-publishing increments the version.** Publishing a draft makes it executable; publishing an already-published workflow (after definition edits) creates a new immutable version, so integrations pinned to the previous version are unaffected.
**Archive** takes the workflow out of service: it stops being executable and disappears from the default list. It is idempotent (archiving twice is fine) and reversible only via **clone** — an archived workflow can be neither published nor reverted (`422` `invalid_state`).
**Revert** sends a *published* workflow back to `draft` — it stops being executable until published again. Reverting an archived workflow is `422` (`invalid_state`).
**Clone** creates a brand-new draft (`201`, new `wf_` ID) with the source’s current definition. It works on any status — it is also the way to “resurrect” an archived workflow’s logic:
```jsonc
// POST /workflows/{id}/clone
{ "name": "Newsletter Hero v2", "description": "…", "tags": ["…"] } // name required;
// description/tags inherited if absent
```
| Transition | Allowed from | Refused with |
| ---------- | ----------------------------- | --------------------------------------------------------------------------- |
| `publish` | draft, published (re-publish) | `422 invalid_state` (archived), `422 validation_error` (invalid definition) |
| `archive` | any (idempotent) | – |
| `revert` | published (draft = no-op) | `422 invalid_state` (archived) |
| `clone` | any | – (plan workflow cap applies: `402`) |
***
## 8. Estimating cost (credits)
[Section titled “8. Estimating cost (credits)”](#8-estimating-cost-credits)
```plaintext
POST {BASE_URL}/api/v1/workflows/{id}/estimate
```
Answers “*what will a run of this cost?*” **before** you execute — in **credits**, per node and in total. Like validate, it has two definition modes: without `definition` it estimates the **stored** definition; with `{ "definition": { … } }` it estimates that document pre-save (blocking mapping errors → the usual `422` envelope). The optional `inputs` and `interface` fields describe the intended runtime context and use the same input shape as execution.
For example, pass an uploaded dataset when its row count controls paid fan-out:
```json
{
"inputs": {
"product_dataset": {
"asset_path": "org/workspace/assets/products.xlsx"
}
}
}
```
For the canonical `input/data` → `enumerate/data_rows` topology, Madoo counts with the same format, table, header, locale, delimiter and row-window configuration used at runtime. The selected row count therefore becomes the exact multiplier for downstream paid nodes before the hold is created. This is a count-only preflight: the projector does not materialize iterations or invoke providers.
```jsonc
{
"total_estimated_credits": 8,
"total_expected_milli_credits": 8000,
"total_reserved_milli_credits": 8300,
"projection_status": "exact",
"is_approximate": true,
"nodes": [
{ "node_id": "hero", "type": "ai/lifestyle", "model_name": "FLUX.2 Dev Edit",
"estimated_credits": 5, "cardinality": 1,
"unit_expected_milli_credits": 5000, "unit_reserved_milli_credits": 5000,
"expected_milli_credits": 5000,
"reserved_milli_credits": 5000, "projection_status": "exact", "is_approximate": false },
{ "node_id": "copy", "type": "ai/text_generation", "model_name": "Claude Sonnet",
"estimated_credits": 3, "cardinality": 1,
"unit_expected_milli_credits": 3000, "unit_reserved_milli_credits": 3300,
"expected_milli_credits": 3000,
"reserved_milli_credits": 3300, "projection_status": "exact", "is_approximate": true,
"warning": "Token-based model: actual cost depends on prompt and output length." }
],
"warnings": []
}
```
Only billable nodes appear (AI and projected sub-workflow liability; inputs, outputs and ordinary transformations are free). `cardinality` is the number of executions propagated through the graph: distinct enumerator roots multiply, while a shared root is counted once. Milli-credit totals are the authoritative values; `total_estimated_credits` and each `estimated_credits` are rounded compatibility views. The `unit_*` fields are the shared runtime price for one invocation; the non-unit liability fields are that price multiplied by `cardinality`.
`projection_status: "exact"` means all paid cardinalities are known. `"deferred"` means at least one node cannot be fully priced yet; `deferral_reason` on that node explains which of the two kinds it is, and in both cases the top-level milli totals are only the known subtotal, not a full quote.
**Deferred kind 1 — the count is unknown** (`deferral_reason` reports a fan-out cause, e.g. `dynamic_enumerator`). The node runs an unknown number of times, so `cardinality` and the total liability fields are null, while `unit_expected_milli_credits` and `unit_reserved_milli_credits` **remain populated** whenever the provider model is resolvable: a client can display the server-owned per-invocation price and only the multiplier is missing.
For `enumerate/data_rows`, `runtime_input_required` usually means the estimate did not receive the dataset asset (or that `input/data` is fed by another runtime node). Submit the same `asset_path` you intend to execute. `dataset_inspection_unavailable` is retryable storage/inspection unavailability; `dataset_tenant_context_required` is reserved for an internal projection surface without workspace context. Invalid datasets or mappings fail preflight instead of being converted into a deferred zero.
**Deferred kind 2 — the unit price is unknown** (`deferral_reason: "runtime_unit_price"`). This is the mirror image: the node runs a known number of times, but its price depends on a media fact the server measures immediately before dispatch (today: the duration of the input video for a runtime-priced model such as Gemini Omni Flash `/edit`). So `cardinality` **is** populated while `estimated_credits`, both `unit_*` fields and both total liability fields are `null`.
```jsonc
{ "node_id": "restyle", "type": "ai/video_transform", "model_name": "Gemini Omni Flash Edit",
"estimated_credits": null, "cardinality": 1,
"unit_expected_milli_credits": null, "unit_reserved_milli_credits": null,
"expected_milli_credits": null, "reserved_milli_credits": null,
"projection_status": "deferred", "deferral_reason": "runtime_unit_price" }
```
> **Client contract.** These money fields are always *present and null*, never omitted and never `0`. A null means “not priced yet”, which is different from “free” — a client that coalesces it to zero will show a run as costless and then be charged. Branch on `projection_status`/`deferral_reason` before reading any amount, and treat `estimated_credits` as nullable: it became so when `runtime_unit_price` was introduced, and a client that types it as a non-nullable number will fail on a workflow containing such a node. The authoritative price for these nodes is settled at dispatch and is visible on the execution, not on the estimate. When executing such a workflow, provide the recommended operational bounds documented in [04-executions §1.2](/public-api/executions/#12-runtime-sized-fan-out-limits). They stop work earlier than the unconditional balance gate; public REST/MCP callers may omit them, while agent-initiated runs require them.
For deterministic per-unit models expected and reserved match. Token-priced models remain approximate (`is_approximate: true` + warning), so reservation can exceed expected while actual charge settles on real usage. A workflow with no billable nodes returns zero exact totals.
> Estimates use the same model resolution as execution (your workspace’s defaults and overrides), so the figure reflects *your* configuration — and it is always expressed in credits; the public API never exposes the underlying USD economics.
***
## 9. The full authoring loop, end to end
[Section titled “9. The full authoring loop, end to end”](#9-the-full-authoring-loop-end-to-end)
```bash
# 1. Discover the building blocks (chapter 09)
curl -s -H "Authorization: Bearer $TOKEN" "$BASE_URL/api/v1/node-types" | jq '.data[].type'
# 2. Create (draft) — idempotent against retries
WF=$(curl -s -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: my-import-001" \
-d @workflow.json "$BASE_URL/api/v1/workflows" | jq -r '.id')
# 3. Iterate: pre-flight a change, then apply it under If-Match
curl -s -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d @candidate.json "$BASE_URL/api/v1/workflows/$WF/validate" | jq '.valid, .authoring_ready, .errors, .warnings'
ETAG=$(curl -sI -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/api/v1/workflows/$WF/definition" | grep -i '^etag:' | cut -d' ' -f2 | tr -d '\r')
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "If-Match: $ETAG" \
-d @candidate.json "$BASE_URL/api/v1/workflows/$WF/definition" | jq '.version, .meta.warnings'
# 4. Check the price tag, then go live (include runtime inputs when they determine fan-out)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"inputs\":{\"product_dataset\":{\"asset_path\":\"$DATASET_PATH\"}}}" \
"$BASE_URL/api/v1/workflows/$WF/estimate" | jq '.projection_status, .total_estimated_credits'
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/api/v1/workflows/$WF/publish" | jq '.status, .version'
# 5. Execute (chapter 04)
```
***
**Next:** [09-catalog.md](/public-api/catalog/) is the companion reference for every key you write in a definition; [04-executions.md](/public-api/executions/) for running what you built.
# Batch executions (advanced)
> **When do you need this?** When you want to run the **same workflow over many input sets** in one go — e.g. generate a hero image for every product in a catalogue. A batch creates and manages many individual executions for you, with a concurrency cap, aggregate progress, and one place to monitor, cancel, or retry. If you only ever run one execution at a time, you can skip this page and use [04-executions.md](/public-api/executions/).
A batch is itself an asynchronous resource (ID prefix `bat_…`). Creating one fans out into N ordinary executions (`run_…`), one per input item.
***
## 1. Create a batch
[Section titled “1. Create a batch”](#1-create-a-batch)
```plaintext
POST {BASE_URL}/api/v1/batch-executions
```
| Field | Type | Required | Default | Description |
| ------------------- | ------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------- |
| `workflow` | string | ✅ | – | The workflow to run (`wf_…`, must be published). |
| `items` | array | ✅ | – | One object per execution. Each maps the workflow’s input keys to values (see [§2](#2-the-items-array)). At least one item. |
| `version` | integer | – | latest | Pin a workflow version. |
| `interface_id` | string | – | default | Run every item through a custom interface ([03-workflows §5](/public-api/workflows/#5-custom-interfaces)). |
| `name` | string | – | – | A display name for the batch. |
| `concurrency_limit` | integer | – | 5 | How many items run simultaneously (1–50). |
| `auto_start` | boolean | – | true | Start immediately. Set `false` to create in a ready state and start later with `POST .../start`. |
| `stop_on_error` | boolean | – | false | Cancel the remaining items the first time an item fails. |
```bash
curl -s -X POST "$BASE_URL/api/v1/batch-executions" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{
"workflow": "'"$WF"'",
"name": "Spring catalogue",
"concurrency_limit": 10,
"items": [
{ "block_title": "Spring in Bloom", "product_image_0": "uploads/ws-12/a3/p1.jpg" },
{ "block_title": "Evening Glow", "product_image_0": "uploads/ws-12/a3/p2.jpg" },
{ "block_title": "Morning Citrus", "product_image_0": "uploads/ws-12/a3/p3.jpg" }
]
}'
```
Returns **HTTP 202 Accepted** with the batch object (see [§4](#4-the-batch-object)), including an `input_summary` describing how many items were created and whether any were skipped or invalid.
***
## 2. The `items` array
[Section titled “2. The items array”](#2-the-items-array)
Each element of `items` is **one execution’s inputs**, expressed as a flat object that maps the workflow’s input keys (the `name`s from the default interface, or the `key`s from the `interface_id` you chose) to values:
* **Text / number / boolean / JSON inputs** → the value directly.
* **File inputs** (`image`, `video`, …) → the **`asset_path`** string of a file you uploaded first via [05-assets.md](/public-api/assets/).
```jsonc
"items": [
{ "block_title": "Spring in Bloom", "product_image_0": "uploads/ws-12/a3/p1.jpg" },
{ "block_title": "Evening Glow", "product_image_0": "uploads/ws-12/a3/p2.jpg" }
]
```
> **Note the difference from a single execution.** In `POST /api/v1/executions`, each input is wrapped (`{ "value": … }` or `{ "asset_path": … }`). In a **batch item**, the inputs are a plain flat map keyed by the interface field. Discover the exact keys with `GET /api/v1/workflows/{id}` ([03-workflows.md](/public-api/workflows/)) and validate one item as a single execution first, then scale it out as a batch.
`input_summary` in the response tells you how the engine interpreted your items:
```jsonc
"input_summary": {
"items_created": 3,
"duplicates_skipped": 0,
"validation_errors": [ { "item_index": 2, "error": "Missing required field 'product_image_0'." } ]
}
```
***
## 3. Monitoring, starting, cancelling, retrying
[Section titled “3. Monitoring, starting, cancelling, retrying”](#3-monitoring-starting-cancelling-retrying)
| Operation | Endpoint |
| ----------------------------------- | ------------------------------------------------------------------------ |
| List batches | `GET /api/v1/batch-executions?limit=&starting_after=&status=` |
| Get one batch | `GET /api/v1/batch-executions/{id}` |
| List a batch’s items | `GET /api/v1/batch-executions/{id}/items?limit=&starting_after=&status=` |
| Start (when `auto_start` was false) | `POST /api/v1/batch-executions/{id}/start` |
| Cancel the batch + pending items | `POST /api/v1/batch-executions/{id}/cancel` |
| Retry all failed items | `POST /api/v1/batch-executions/{id}/retry-failed` |
```bash
# Poll the batch until it is done
curl -s -H "Authorization: Bearer $TOKEN" "$BASE_URL/api/v1/batch-executions/$BATCH_ID" \
| jq '{status, progress_percent, completed_items, failed_items, total_items}'
```
`start`/`cancel`/`retry-failed` return **422** (`invalid_state` / `no_failed_items`) when the batch is not in a state that allows the operation.
### Inspecting items
[Section titled “Inspecting items”](#inspecting-items)
`GET .../items` returns each item with its `item_index`, `status`, the `inputs` you sent, and — once it is running or done — the linked execution ID under `execution` (a `run_…`). Use that to drill into a single item’s outputs via the normal execution endpoints ([04-executions.md](/public-api/executions/)).
```jsonc
{
"item_index": 0,
"status": "completed",
"input_label": "Spring in Bloom",
"execution": "run_b4c8d3e2…", // ← fetch this for the item's outputs
"created_at": "2026-05-27T15:00:00+00:00",
"completed_at": "2026-05-27T15:00:21+00:00"
}
```
***
## 4. The batch object
[Section titled “4. The batch object”](#4-the-batch-object)
```jsonc
{
"id": "bat_9a1c…",
"workflow": "wf_53fb…",
"workflow_version": 4,
"interface_id": null,
"name": "Spring catalogue",
"status": "running", // pending | running | completed | partial_success | failed | cancelled
"input_mode": "json",
"total_items": 3,
"pending_items": 0,
"running_items": 1,
"completed_items": 2,
"failed_items": 0,
"cancelled_items": 0,
"progress_percent": 66.7,
"duplicates_skipped": 0,
"estimated_credit_cost": 24,
"actual_credit_cost": 16,
"concurrency_limit": 10,
"auto_start": true,
"stop_on_error": false,
"created_at": "2026-05-27T15:00:00+00:00",
"started_at": "2026-05-27T15:00:01+00:00",
"completed_at": null
}
```
A batch reaches `partial_success` when it finishes with some failed items — use `retry-failed` to re-run just those.
***
## 5. ZIP export of a batch
[Section titled “5. ZIP export of a batch”](#5-zip-export-of-a-batch)
Same three-step flow as executions, but the **batch ZIP endpoints take the prefixed `bat_…` ID** (unlike the execution ZIP endpoints, which take a raw GUID — see [04-executions §8](/public-api/executions/#8-exporting-all-outputs-as-a-zip)):
```bash
curl -s -X POST -H "Authorization: Bearer $TOKEN" "$BASE_URL/api/v1/batch-executions/$BATCH_ID/zip"
curl -s -H "Authorization: Bearer $TOKEN" "$BASE_URL/api/v1/batch-executions/$BATCH_ID/zip/status"
curl -sL -H "Authorization: Bearer $TOKEN" "$BASE_URL/api/v1/batch-executions/$BATCH_ID/zip/$ZIP_ID/download" -o batch.zip
```
Only available once the batch is terminal; large exports may split into multiple parts.
***
**Next:** [07-advanced-embed.md](/public-api/embed/) *(advanced)* — embedding a workflow as a widget on your own website.
# Catalog discovery (node types, models, presets, effects)
The endpoints in the previous chapters are about *running* workflows someone has already built. This chapter is about the **catalog**: the building blocks workflows are made of. The catalog endpoints are read-only and answer four questions:
1. **What kinds of nodes exist?** → node types (`GET /api/v1/node-types`)
2. **Which AI models can a node use, and which one is the default?** → models per node type (`GET /api/v1/node-types/{category}/{name}/models`)
3. **What models and capabilities exist overall?** → `GET /api/v1/models`, `GET /api/v1/capabilities`
4. **What curated style options can I reference?** → presets and effects (`GET /api/v1/presets`, `GET /api/v1/effects`)
The catalog is useful for transparency (e.g. showing your users which model powers a node, and what it costs in credits), and it is the companion reference for the **workflow authoring API** ([10-authoring.md](/public-api/authoring/)): every node `type`, `model` slug, and preset/effect `code` you write in a definition must come from this catalog.
> **Conventions.** All endpoints require the standard bearer token and workspace context ([01-authentication.md](/public-api/authentication/)), are rate-limited like the rest of the V1 API, and return the standard paginated envelope for lists. The catalog is small, so by default list responses are **returned whole** (`has_more: false`) — fetch once and work from the full set. Every catalog list also accepts optional `limit` (max 100) + `starting_after` if you prefer paging: the cursor is the item’s stable key (`type` for node types, `model` for models, `{category}/{code}` for presets and effects, `capability` for capabilities) and pages carry `next_cursor` like every other V1 list. The catalog is **English-only in V1**: responses ignore `Accept-Language` and always carry `Content-Language: en`.
> **Catalog keys are readable slugs, not opaque IDs.** Resources you *own* (workflows, runs) use prefixed opaque IDs (`wf_…`, `run_…`). Catalog entries are identified by human-readable, stable keys instead — node type `"ai/lifestyle"`, model `"flux-2-dev-edit"`, preset `"minimalist"` — because they are meant to be written by hand (and by agents) inside workflow definitions.
***
## 1. Node types
[Section titled “1. Node types”](#1-node-types)
A **node type** is a kind of step you can place in a workflow: an input (`input/image`), an AI generation step (`ai/lifestyle`), a transformation, an output (`output/image`), and so on. The type identifier is always exactly two segments: `category/name`.
### 1.1 List
[Section titled “1.1 List”](#11-list)
```plaintext
GET {BASE_URL}/api/v1/node-types
GET {BASE_URL}/api/v1/node-types?category=AI
GET {BASE_URL}/api/v1/node-types?search=video%20merge
```
| Query param | Type | Description |
| ----------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `category` | string | Optional filter by category (`AI`, `Input`, `Output`, `Image Processing`, …), case-insensitive. |
| `search` | string | Optional free-text query. The query is split into words (on spaces, `/`, `-`, `_`, `.`); a node matches when **every** word is found in at least one of its fields (type, name, description, when-to-use, category, capabilities), so multi-word queries like `video merge` or `split-video` work where a single contiguous substring would not. Results are ranked by relevance (then type); without `search` the catalog keeps its category/type order. This is the same matching/ranking the MCP `search_node_types` tool uses, so both return the same ordering. Use few, broad English outcome words. |
> Tip for agents: when a multi-word `search` returns nothing, retry with fewer/broader words rather than a translated phrase — every word must match some field.
Each item describes the node type’s contract — its input/output **ports** and its **parameters**:
```jsonc
{
"data": [
{
"type": "ai/lifestyle",
"version": "1.0.0",
"name": "Lifestyle Scene",
"description": "Places a product photo in a generated lifestyle environment.",
"when_to_use": "Use when a product image must be placed in a generated lifestyle scene.",
"category": "AI",
"sub_category": "Generation",
"status": "stable", // "stable" | "preview" | "deprecated" | "latest"
"supports_effects": true,
"enumerable": false, // true when the node produces an iterable output
"inputs": [
{ "name": "product_image", "type": "image", "required": true, "label": "Product Image",
"multi": false, "enumerable": false },
{ "name": "reference_image", "type": "image", "required": false, "label": "Reference",
"multi": false, "enumerable": false }
],
"outputs": [
{ "name": "result", "type": "image", "required": false, "multi": false, "enumerable": false }
],
"parameters": [
{ "name": "mood", "type": "preset", "category": "mood", "required": false,
"advanced": false, "exposable": true },
{ "name": "custom_prompt", "type": "text", "required": false,
"advanced": false, "exposable": true }
],
"icon": "sparkles",
"color": "#FF8800"
}
],
"has_more": false,
"total_count": 42
}
```
Reading the contract:
* **`inputs` / `outputs`** are the node’s ports — what flows in and out when the workflow runs. Array ports use a `[]` type suffix (e.g. `"image[]"`) with optional `min_count` / `max_count`. An input may list **`accepts`**: further source types it takes besides its own `type`. `output/json`’s `json` port has `"accepts": ["number", "boolean"]`, so one computed number (`utility/calculate`) or one decision (`utility/predicate`) can be a workflow result; it reads back as that number or boolean in `.value`.
* **`parameters`** are the node’s configuration knobs. The `type` tells you what kind of value the parameter takes; two types deserve attention:
* `"preset"` — the value is a preset `code` from the category given in the parameter’s `category` field (see §4);
* `"effect"` — the value is an effect `code` (see §4).
A parameter with **`visible_when`** (`{ "parameter": "symbology", "value": "qr" }`) applies only when the other parameter has that value — or one of the values, when `value` is a list (`{ "parameter": "symbology", "value": ["ean13", "code128"] }`); the other parameter’s default counts when it is not set. Editors hide it otherwise.
* **`status`** is the lifecycle stage. Avoid building new integrations on `deprecated` types.
* **`when_to_use`** explains the practical intent and nearby alternatives. Agents should use it together with the typed contract instead of choosing from the node name alone.
### 1.2 Detail
[Section titled “1.2 Detail”](#12-detail)
```plaintext
GET {BASE_URL}/api/v1/node-types/{category}/{name}
```
e.g. `GET /api/v1/node-types/ai/lifestyle`. Same shape as the list item, plus the **`capabilities`** array: the abstract abilities this node needs from an AI model (for AI nodes), e.g. `["image_to_image"]`. Capabilities are the link between node types and models — which brings us to the key endpoint of this chapter.
#### Structured JSON and compact array ports
[Section titled “Structured JSON and compact array ports”](#structured-json-and-compact-array-ports)
A JSON port or structured parameter can carry **`schema_ref`**, for example `madoo.quality-check/v1`, `madoo.quality-policy/v1` or `madoo.audio-quality-report/v1`. This is a versioned Madoo contract, not merely a descriptive label: use it to understand and validate the exact document shape before composing a workflow. Different ports with the generic type `json` are compatible only when their declared contracts and the intended transformation agree.
`design/template_render` publishes three structured JSON outputs. `pages_manifest` uses `madoo.design-pages-manifest/v1` to list selected page images and their durable references; `layout_report` uses `madoo.template-layout-report/v1` to explain page selection, repeated content, image loading and render timings; `quality_check` reuses `madoo.quality-check/v1` for a portable health decision. Resolve each `schema_ref` through `GET /api/v1/json-schemas/{schema_ref}`. The editor uses the same catalogue for output-port hover previews and its Output structures inspector.
Node parameters marked `hidden: true` are server-managed technical metadata. Authoring clients should present them read-only, not as normal editing controls. In `design/template_render`, `document/pdf` and `aggregate/pdf` this includes the numeric document/version IDs, GUIDs and hashes the editor stores; the detail lists instead the public selector parameters **`template_id`** (required) and **`template_revision`**, with their meaning for that node (MCP `get_node_type` and the agent’s `catalog.get_node_type` show the same two and omit the internal ones).
To choose a published DesignDocument template and discover the placeholder codes for its `data` input, use the [design template discovery API](/public-api/design-templates/). REST v1 and MCP return the same immutable version contract and portable `tpl_` identifier.
An array input with **`compact: true`** uses one logical array handle in the editor but accepts several individual connections. In a workflow definition those connections are named by appending a zero-based index to the catalog port name: `checks_0`, `checks_1`, … up to `max_count - 1`. `min_count` and `max_count` apply to the number of connected values. A producer that already emits one JSON array may instead connect directly to the base port (`checks`); never mix the base-array and indexed forms in the same node instance.
`audio/quality_report` is a zero-provider-credit example with two structured outputs. `metrics` uses `madoo.audio-quality-report/v1` for loudness, true/sample peak, near-clipped sample count and silence; `quality_check` uses the common `madoo.quality-check/v1` contract and can be connected directly to `utility/quality_gate`. Catalog consumers should preserve both references: the detailed measurement report and the portable gate check serve different purposes.
`audio/loudness_normalize` adds a media output and three structured views of the transformation. Both `before` and `after` use `madoo.audio-quality-report/v1`; `processing_report` uses `madoo.audio-processing-report/v1`; `quality_check` uses `madoo.quality-check/v1`. The distinction is intentional: the audio is the deliverable, the before/after reports are measured facts, the processing report explains what was applied, and the check is the compact decision consumable by Quality Gate.
`audio/fit_duration` accepts a required `audio`, optional dynamic `target_duration`, and the paired `target_start` / `target_end` inputs. For caption-driven dubbing, connect start and end from the same cue; their difference is authoritative. Otherwise the dynamic duration takes precedence over the saved parameter. Its `actual_duration` output is the measured result in seconds; `fit_report` uses `madoo.audio-processing-report/v1`, and `quality_check` uses `madoo.quality-check/v1`. Catalog clients must retain the `fail`, `clamp`, and `warn` values of `on_out_of_range`: they are materially different authoring choices, and no tempo-rate excursion is implicit.
`aggregate/audio_timeline` is the domain-specific barrier for localized speech. Its `clips`, `cue_id`, `start`, and `end` inputs must be aligned iteration outputs from one root enumeration; `speaker` is optional but follows the same rule. A dynamic `timeline_duration` in seconds overrides the saved fallback. Unlike `aggregate/audio_merge`, it restores absolute gaps as silence and makes missing cues and overlaps explicit. The `timeline_manifest` output uses `madoo.audio-timeline/v1`; `quality_check` uses `madoo.quality-check/v1`. Catalog clients must preserve the `fail|mix|trim_previous` overlap policy and the `fail|warn` missing-cue policy rather than reducing them to a generic merge option.
`utility/dubbing_manifest` is the deterministic final barrier for a localized delivery. It consumes complete source/target caption tracks, an exact per-cue evidence array, the typed timeline manifest, measured master and final media, stable media assets and applicable Quality Checks. Its `manifest` output uses `madoo.dubbing-manifest/v1`; `quality_check` uses `madoo.quality-check/v1`. Catalog clients must preserve the `background_mode`, `speaker_voice_source`, `require_final_video` and advanced tolerance/check-id controls. The node is not a generic JSON generator: it rejects missing or duplicate cue evidence and emits only stable `storage://` references, never signed delivery URLs.
`audio/duck` exposes two required audio inputs: `foreground` is both the audible foreground and the sidechain detector, while `background` is attenuated and mixed. `ducking_report` uses `madoo.audio-processing-report/v1` and contains the resolved profile, FFmpeg’s linear threshold, observed integrated background reduction, durations and final signal measurements. `quality_check` uses `madoo.quality-check/v1`. Numeric ducking controls intentionally have no catalog default: clients should show the selected profile’s published values until the author explicitly creates an override.
`audio/speech_enhance` accepts one storage-backed `audio` input and returns cleaned `audio`, a `processing_report` using `madoo.audio-processing-report/v1`, and a portable `quality_check` using `madoo.quality-check/v1`. Its `light`, `podcast`, `dialogue`, and `custom` profiles are transparent baselines; numeric cleanup controls intentionally have no catalog default so an authoring client can show the inherited profile value without persisting it as an override. `denoise_technique=auto` currently resolves to the qualified FFT chain and the report publishes the resolved value and exact filters. The node does not claim dereverberation or source separation; those are separate capabilities.
For every Audio Finishing node, catalog clients must preserve the parameter `advanced` flag. The primary authoring surface is intentionally limited to workflow decisions such as profile, processing mode, output format and failure behaviour; stable check identifiers and technical profile overrides are fine-tuning controls. An omitted optional numeric override is not the parameter minimum: clients should display the inherited profile value when one exists, or an unset state when the selected profile intentionally defines no value. Humanized labels may be shown, but serialized enum values such as `two_pass` and `voice_over` must remain unchanged.
### 1.3 Dynamic ports (config-driven nodes)
[Section titled “1.3 Dynamic ports (config-driven nodes)”](#13-dynamic-ports-config-driven-nodes)
Some nodes don’t have a fixed port list — their ports depend on a configuration parameter. The detail response advertises this with **`roles`** (e.g. `["dynamic_outputs"]`, `["aggregator","dynamic_inputs"]`), a **`has_dynamic_ports`** flag, a **`dynamic_ports`** descriptor array, and a **`value_schema`** on the driving parameter. The derivations:
* **`derivation: "named"`** — the entries of a composite-config array become ports. The descriptor names the `parameter` you write, the `items_path` array inside it, and the `name_field`/`type_field` that become each port’s name/type (e.g. `enumerate/json` → `jsonConfig.properties[].outputPort`; `aggregate/json` → `jsonAggregateConfig.columns[].inputPort`). Write the object described by the parameter’s `value_schema`; the `outputPort`/`inputPort` values you choose become the wireable ports. When every port has the same fixed type the descriptor carries a constant `port_type` instead of a `type_field` — e.g. **`text/template`**, whose `placeholders` config (`{ "placeholders": [ { "inputPort": "..." } ] }`) turns each `inputPort` into a `text` **input** port. The port name IS the literal `[inputPort]` marker the node replaces in its template text; the reserved name `template` (the node’s static template input) is rejected. A `text/template` node needs either a literal `template` parameter or an inbound connection to its `template` input, or authoring fails (`TEXT_TEMPLATE_MISSING_TEMPLATE`).
**Computed columns.** A CSV or JSON enumerator mapping may compute its value instead of reading one field: `{ "outputPort": "amount", "outputType": "number", "isComputed": true, "formula": "round(qty * price, 2)" }`. The column’s `outputType` chooses the language. A **number** column takes a `utility/calculate` expression (exact decimals; a wrapping `{{ }}` is accepted). Any other column takes a Scriban text template such as `{{ name | string.upcase }} ({{ sku }})`, where every value is text, `+` joins text and arithmetic operators are rejected. Variables are the row’s fields: CSV headers, JSON keys with `.` written as `_` (`pricing.price` → `pricing_price`). In a number formula a CSV field mapped as a number column is read with that column’s separators. A formula that does not compile is rejected at authoring (`invalid_dynamic_config`, path `…[i].formula`). A row that cannot be computed (unknown field, text in arithmetic, empty cell, division by zero) fails the node with `FORMULA_ERROR`, naming the column and the row; it is never an empty value.
* **`derivation: "positional"`** — a parameter produces indexed ports. The descriptor carries `name_pattern`, `index_base`, `port_type`, `min_count`/`max_count` and a `mode`. Two families:
* **Image variants** (`ai/text_to_image`, `ai/ad_generator`, `ai/product_photography`, `ai/lifestyle`, `ai/showroom_visualizer`, `ai/virtual_tryon`, `ai/background_swap`, `ai/magic_swap`, `ai/style_transfer`, `ai/magic_box`) — driven by the integer **`variants`** parameter (`name_pattern: "variant_{i}"`, `index_base: 1`, `port_type: "image"`, `1..4`, `mode: "single_result_else_variants"`):
* `variants: 1` (or omitted) → a single **`result`** image output.
* `variants: N` (2–4) → **`variant_1` … `variant_N`** image outputs; the static `result` is replaced, so wiring from `result` at `N>1` is rejected. Wire from `variant_1`/`variant_2`/… instead.
* `variants` outside `1..4`, or a non-integer value, is rejected at authoring (`invalid_dynamic_config`).
* **Split** (`video/split`, `audio/split`) — driven by the **`segments`** array parameter (`name_pattern: "segment_{i}"`, `index_base: 0`, `port_type: "video"`/`"audio"`, `1..20`). Write `segments` as an array of `{ "start": "...", "end": "..." }` time strings (seconds or `HH:MM:SS`); you get **`segment_0` … `segment_{N-1}`** outputs, one per range. At least one segment is required (an empty/absent `segments`, a non-array, a non-object item, or a non-string `start`/`end` is rejected at authoring), max 20.
* **`derivation: "expression_variables"`** — every variable of an expression becomes a required input port of `port_type` (at most `max_count`). Used by **`utility/calculate`**: write `expression`, e.g. `round(subtotal * (1 + vat_rate), 2)` or `sum(quote.items[*].amount)`, and connect a number or a JSON value to each variable (`subtotal`, `vat_rate`, `quote`). Functions (`round`, `floor`, `ceil`, `abs`, `min`, `max`, `sum`, `count`, `avg`, `if`), `true`/`false` and the fields after a `.` are not variables. An expression that does not parse is rejected at authoring (`invalid_dynamic_config`, path `expression`, with the character position). Every variable must be connected (`CALC_UNCONNECTED_VARIABLE` otherwise), and only from a `number`, `json`, `boolean` or `any` output: text or a file is rejected with `CONNECTION_TYPE_MISMATCH` (a CSV or JSON enumerator column feeding a variable must be number-typed). The arithmetic is exact decimal; a text value is never read as a number; division by zero, a missing JSON field or an empty list fails the node with a `CALC_*` code instead of producing an empty value.
* **Template field ports** (`design/template_render`, `document/pdf`, `aggregate/pdf`) — the input ports are the field codes of the template chosen with `template_id`, typed like the fields. They depend on the template, not on the node type, so the node detail cannot list them: read them from the template’s contract (`GET /api/v1/design-templates/{tpl_id}/versions/{revision}/contract`, MCP `get_design_template`). A connection to a code the template does not have fails with `unknown_port`, whose message lists the valid codes. `aggregate/pdf` keeps its static `introPdf` / `outroPdf` inputs next to them.
***
## 2. Models for a node type (the key endpoint)
[Section titled “2. Models for a node type (the key endpoint)”](#2-models-for-a-node-type-the-key-endpoint)
```plaintext
GET {BASE_URL}/api/v1/node-types/{category}/{name}/models
```
For each capability the node type requires, this returns **the models you can select** and **the default that will be used if you select none**:
```jsonc
{
"node_type": "ai/lifestyle",
"capabilities": [
{
"capability": "image_to_image",
"display_name": "Image to Image",
"default_model": "flux-2-dev-edit", // the EFFECTIVE default for YOUR workspace
"default_source": "workspace_override", // "system" | "workspace_override"
"models": [
{
"model": "flux-2-dev-edit",
"display_name": "FLUX.2 Dev Edit",
"provider": "Fal.ai",
"credit_cost": 8,
"credit_cost_milli_credits": 8000,
"cost_unit": "per_image",
"cost_basis": "per_unit",
"quality_tier": 4,
"speed_tier": 3,
"supports_vision": false,
"is_default": true
}
// … more models …
]
}
]
}
```
Three things to understand:
* **The default is *effective*, per workspace.** A workspace admin can override the system default model for any node type in the Madoo dashboard. This endpoint resolves the default *for the workspace your token belongs to* and tells you where it came from (`default_source`). Two API keys from different workspaces can legitimately see different defaults.
* **Economics are in credits, always.** `credit_cost_milli_credits` is the exact base cost per `cost_unit` (1 credit = 1,000 milli-credits); prefer it for calculations and display. `credit_cost` remains the backward-compatible whole-credit view. The units are (`per_image`, `per_second`, `per_1k_tokens`, `per_1k_characters`, `per_megapixel`, `per_request`). For token-based (LLM) models the real cost depends on prompt/response length, so they are flagged `cost_basis: "per_call_estimate"` — treat their `credit_cost` as an estimate per call. Character-priced TTS models report `per_1k_characters`; when text is supplied at runtime the execution preview is deferred until that exact text is frozen before provider dispatch.
* **Multi-capability nodes return multiple entries** — one per capability, each with its own model list and default.
***
## 3. Models and capabilities (flat views)
[Section titled “3. Models and capabilities (flat views)”](#3-models-and-capabilities-flat-views)
### 3.1 All models
[Section titled “3.1 All models”](#31-all-models)
```plaintext
GET {BASE_URL}/api/v1/models
GET {BASE_URL}/api/v1/models?capability=image_to_image
```
The same model objects as §2, with one addition: each model carries its `capabilities` array. Use this for a global “which models does Madoo offer” view; use §2 when you care about a specific node.
### 3.2 Model parameter schema
[Section titled “3.2 Model parameter schema”](#32-model-parameter-schema)
```plaintext
GET {BASE_URL}/api/v1/models/{model}/params-schema
```
e.g. `GET /api/v1/models/flux-2-dev-edit/params-schema`. Models can expose their own tunable parameters (temperature, guidance scale, native style options…). The schema tells you what they are, their bounds, and their defaults:
```jsonc
{
"model": "flux-2-dev-edit",
"schema": [
{ "name": "guidance_scale", "type": "number", "label": "Guidance Scale",
"min": 1, "max": 20, "step": 0.5, "default": 7.5, "advanced": true }
],
"defaults": { "guidance_scale": 7.5 },
"hides_node_parameters": [] // node parameters superseded when this model is selected
}
```
`hides_node_parameters` matters when a model brings a *native* version of something the node also offers generically: the listed node parameters are ignored while this model is selected.
### 3.3 Capabilities
[Section titled “3.3 Capabilities”](#33-capabilities)
```plaintext
GET {BASE_URL}/api/v1/capabilities
```
The full capability vocabulary (`image_to_image`, `text_to_video`, …) with display names and categories. Useful for building model-picker UIs or for filtering `GET /api/v1/models`.
***
## 4. Presets and effects
[Section titled “4. Presets and effects”](#4-presets-and-effects)
**Presets** are curated style options (environments, surfaces, moods…) and **effects** are visual treatments (film grain, chrome…). Workflow nodes reference them **by `code`** in their parameters (see §1.1). The catalog gives you the codes plus display metadata — the prompt engineering behind each entry is Madoo’s, and is not exposed.
```plaintext
GET {BASE_URL}/api/v1/presets
GET {BASE_URL}/api/v1/presets?category=environment
GET {BASE_URL}/api/v1/effects
GET {BASE_URL}/api/v1/effects?category=metallic
```
| Query param | Type | Description |
| ----------- | ------ | ---------------------------------------------------------------------------------- |
| `category` | string | Optional filter by category code, case-insensitive. Unknown category → empty list. |
```jsonc
// GET /api/v1/presets?category=environment
{
"data": [
{
"code": "white_studio",
"name": "White Studio",
"description": "Clean white studio set with soft, even lighting.",
"category": "environment",
"icon": "studio",
"thumbnail": "https://…/white_studio.jpg",
"sort_order": 1
}
],
"has_more": false,
"total_count": 1
}
```
Effects additionally carry intensity metadata:
```jsonc
{
"code": "foil_gold",
"name": "Gold Foil",
"category": "metallic",
"supports_intensity": true, // can be applied with an intensity…
"default_intensity": 50, // …and this is the default (0–100)
"sort_order": 1
}
```
***
## 5. Endpoint summary
[Section titled “5. Endpoint summary”](#5-endpoint-summary)
| Endpoint | Returns |
| --------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `GET /api/v1/node-types` (`?category=`, `?search=`) | All active node types with ports and parameters; `search` filters+ranks by relevance |
| `GET /api/v1/node-types/{category}/{name}` | One node type, including its `capabilities` |
| `GET /api/v1/node-types/{category}/{name}/models` | Selectable models + effective default per capability |
| `GET /api/v1/models` (`?capability=`) | All enabled models with capabilities |
| `GET /api/v1/models/{model}/params-schema` | Tunable parameters of one model |
| `GET /api/v1/capabilities` | The capability vocabulary |
| `GET /api/v1/presets` (`?category=`) | Preset codes + display metadata |
| `GET /api/v1/effects` (`?category=`) | Effect codes + intensity + display metadata |
All list endpoints return the standard envelope with `has_more: false`; detail endpoints return 404 (`code: not_found`) for unknown or inactive entries.
For `ai/text_to_speech`, the node-type detail exposes provider-neutral per-cue input ports while the selected model params schema remains the authority for supported static controls. A connected TTS control overrides the saved model value and is rejected before provider submission when the selected model does not advertise it. The structured `generation_metadata` output uses `madoo.tts-generation/v1`.
For `ai/stem_separation`, catalog clients preserve the four semantic audio outputs (`vocals`, `drums`, `bass`, `other`) and the `stems_manifest` schema reference `madoo.stem-separation/v1`. Its controls are model-owned fine tuning; `other` is one instrumental stem and must not be relabeled as the full background.
# Design templates — document templates and PDF rendering
DD7 lets an integration discover the same published template that the Editor picker uses. The REST v1 routes and MCP tools return a portable field contract and expose no database IDs or mutable full-document body.
## REST v1
[Section titled “REST v1”](#rest-v1)
Use a token with `catalog:read` scope and workspace `WsAssetsRead` permission:
```http
GET /api/v1/design-templates
GET /api/v1/design-templates/{tpl_id}
GET /api/v1/design-templates/{tpl_id}/versions
GET /api/v1/design-templates/{tpl_id}/versions/{revision}/contract
```
`tpl_id` is an opaque `tpl_` prefixed GUID. The list contains published templates in the caller’s workspace, with `name`, `status`, `page_count` and `current_revision`. The contract endpoint returns the exact immutable revision, page count, hashes and `fields` with each placeholder’s `code`, `type`, `required` and optional default. Use the field codes as keys of the JSON object connected to the `design/template_render` `data` port. For the two-page proposal example, the field keys are `customer` and `headline`; a named `headline` port overrides the value in `data`.
`data` accepts several objects: connect them to `data_0`, `data_1`, … and they are merged in index order, a later object overriding an earlier one’s keys (a connection to the unindexed `data` is read first). Use it to keep AI-written copy and verified facts apart — for example the copy produced by an LLM on `data_0` and the offer maintained by the business on `data_1` — instead of splitting the facts into one field input each. Precedence overall: named field inputs, then `assets`, then the merged `data`.
```json
{ "customer": "Acme Studio", "headline": "Proposal for Acme Studio" }
```
A `json` field read by a Repeat region (a list printed one row per item) also lists `item_fields`: the keys each item provides, with `code`, `name`, `type` and `required`. The data shape does not change; for a price list whose row reads `name` and `price`:
```json
{ "items": [ { "name": "Espresso", "price": "1.20" }, { "name": "Tea", "price": "1.80" } ] }
```
With `missing_policy` `fail_required` (the default), an item without a required key stops the render with `DESIGN_PLACEHOLDER_REQUIRED` and the item path, e.g. `items[1].name`.
The `/versions` route lists published revisions newest first with a `current` marker and portable `tplv_` IDs. Choose a revision from this list before requesting its field contract. To author a workflow via REST v1, MCP, or an agent, set `design/template_render.parameters.template_id` to the discovered `tpl_` ID and optionally set `template_revision` to a published revision number. If you omit `template_revision`, Madoo fixes the currently published revision during workflow validation and creation. The definition readback displays the revision it fixed. The server resolves the internal document and version IDs and derives the typed field input ports; the caller never needs to read the Editor’s numeric IDs or supply `templateContract`.
```json
{"id":"render_proposal","type":"design/template_render","parameters":{
"template_id":"tpl_205ed88f82fd2ce57f3587b579705094",
"output":"both"
}}
```
Two other nodes fill a template, selected the same way (`parameters.template_id`, optional `template_revision`, never `documentId`). Their input ports are the template’s field codes, typed like the fields; connect only the fields to fill, the others keep the value authored in the template.
* `document/pdf` renders one PDF. For one document `design/template_render` is generally preferable (structured `data`, pinned revision, page images, layout report, PDF/X-4).
* `aggregate/pdf` turns an iteration into **one** PDF: placed after the per-item branch of an enumerator, it fills the template once per item and appends each item’s pages, with optional `introPdf`/`outroPdf` pages (a cover rendered by `document/pdf` or `design/template_render`). Use it for a catalog, a price list or one sheet per product; `utility/merge_pdf` only joins a fixed list of existing PDFs.
Unlike the renderer these two nodes are not pinned: without `template_revision` they render the template’s current **draft** when the workflow runs, so later draft edits change the output. Set `template_revision` for a reproducible workflow.
```json
{"id":"catalog","type":"aggregate/pdf","parameters":{
"template_id":"tpl_205ed88f82fd2ce57f3587b579705094","template_revision":3,"outputName":"catalog"
}}
```
A different workspace cannot resolve the ID; the lookup is workspace-scoped in the Application service and SQL query. Draft and archived documents are not exposed as selectable published templates, even if their ID and revision are known. Version contracts remain immutable after a later template edit while the template stays published.
## New revisions in a saved workflow
[Section titled “New revisions in a saved workflow”](#new-revisions-in-a-saved-workflow)
To check every `design/template_render` node in a workflow without changing it, call:
```http
GET /api/v1/workflows/{wf_id}/template-revisions
```
This requires `workflows:read` scope plus workflow/template read permissions. The response gives the saved and latest revision, changed field codes and page counts, a compatibility reason, and the workflow definition ETag. `latest_revision` is `null` with an explanation when the source document is not currently Published; an editable draft is never treated as the latest published revision. `get_workflow_template_revisions` in MCP, `workflow.review_template_revisions` in AI Agent, and `review_workflow_template_revisions` in AI Assistant call the same Application review. Checking is read-only. To save a different pin, the author reviews the change and updates the workflow definition in an authoring surface; a Run never saves it.
Migration 480 adds `template_version_policy` to the renderer node. Its default `pinned` uses the saved revision. `latest_compatible` lets a **new execution of the current workflow version** use a newer published revision only when the existing field codes/types, required defaults, page count and repeated regions pass a conservative check. For a repeated region only data-facing changes need review: a removed or retyped item key, a new or newly required item key, a different list, overflow rule or lower Max items, or less room while the rule is to stop. Restyling, moving or resizing the row does not. Otherwise it keeps the saved pin and records the reason. The effective definition, including the choice or fallback, is written to a unique immutable execution snapshot and referenced by the WorkflowTask. Readback and retry use that snapshot. Historical version requests and released App bindings keep their frozen workflow version. AI Agent fixes the workflow version observed during planning; if it is still current when the new run starts, the node’s opt-in policy can resolve a newer template inside that run snapshot. If that workflow version became historical, the agent keeps its observed pin. This policy does not change the workflow’s saved template pin.
The existing REST `PUT /api/v1/workflows/{wf_id}/definition` with `If-Match` and its MCP/Agent draft authoring equivalents can persist a reviewed `template_revision`. The targeted command below updates selected pins under a workflow definition lock. REST, MCP, AI Agent and Editor authoring use that path; Assistant presents an explicit confirmation card in the Editor. Live qualification of these surfaces remains DD7 work.
At workflow publish, selecting the current revision or requesting an upgrade requires the template to be Published. A workflow that already holds an immutable `template_revision` pin can keep using that revision after the source template returns to draft or is archived. The internal workflow publisher checks that an existing pin still carries the same document/version identity and content/contract hashes. Supplying only legacy numeric document and version IDs after archive or revert cannot make a new template selection.
Draft inspection uses the separate `design-templates:read` scope and workspace `WsAssetsRead` permission:
```http
GET /api/v1/design-templates/{tpl_id}/draft
GET /api/v1/design-templates/{tpl_id}/draft/publish-readiness
GET /api/v1/design-templates/{tpl_id}/draft/outline
```
The draft response gives its exact `draft_etag` (also in the `ETag` header), revision and status. The readiness response returns `can_publish`, blocking checks and suggested actions from the same server assessment used by the Editor. A later edit or publish must use the observed ETag as its precondition. This read surface returns no mutable full-document body. The outline lists page IDs, element IDs/names/types and named sample sets with their field codes, without exposing the editable JSON body. Use its page IDs when adding a local element. The first page can also be selected by omitting `page_id`.
## MCP
[Section titled “MCP”](#mcp)
`list_design_templates` returns the same list contract. `get_design_template` reads one template by `view`, each with the same projection as its REST v1 route:
| `view` | REST v1 equivalent | Scope |
| --------------------------------------------------------------- | ---------------------------------------------------------- | ----------------------- |
| `contract` (default; optional `revision`, else the current one) | `GET …/versions/{revision}/contract` | `catalog:read` |
| `versions` | `GET …/versions` | `catalog:read` |
| `draft` | `GET …/draft` | `design-templates:read` |
| `outline` | `GET …/draft/outline` | `design-templates:read` |
| `content` (optional `revision`, else the draft) | `GET …/draft/content`, `GET …/versions/{revision}/content` | `design-templates:read` |
| `readiness` | `GET …/draft/publish-readiness` | `design-templates:read` |
| `palette` (needs `element_id`) | `GET …/draft/elements/{element_id}/palette` | `design-templates:read` |
All views also require `WsAssetsRead`. The contract carries the same portable field codes and hashes as REST v1, so an agent can wire `design/template_render` without guessing the JSON shape. The write scope `design-templates:write` also satisfies draft read, but it is not automatically granted to existing automation clients or default MCP consent; request it explicitly when authoring templates.
## Create a draft
[Section titled “Create a draft”](#create-a-draft)
Use `design-templates:write` and workspace `WsAssetsManage`. A stable 8–255 character `Idempotency-Key` is required. Creation makes a native blank draft, never a published template:
```http
POST /api/v1/design-templates
Idempotency-Key: proposal-template-001
Content-Type: application/json
{ "name": "Proposal template", "description": "Two-page proposal", "tags": ["proposal"] }
```
The response is `201` with the portable `tpl_` ID inside `draft.template.id`, its `draft_etag` and an `ETag` header. Retrying with the same key and details returns that same template and `Idempotency-Replayed: true`; reusing the key with different details returns `409 idempotency_conflict`. The workspace and key are persisted by migration 478. A draft whose metadata was saved just before an interrupted initial content upload is completed on retry.
MCP `create_design_template_draft` takes the same fields and required `idempotency_key`, calls the same Application service, and records a write audit with the template ID and hashed key.
## Build a multipage brochure semantically
[Section titled “Build a multipage brochure semantically”](#build-a-multipage-brochure-semantically)
The draft outline now reports each page’s one-based number, stable ID, name, format, orientation, dimensions, background color and element count. Integrations can build a brochure without downloading or replacing the DesignDocument JSON.
Every page mutation requires the latest `If-Match` and a fresh `Idempotency-Key`:
```http
POST /api/v1/design-templates/{tpl_id}/draft/pages
POST /api/v1/design-templates/{tpl_id}/draft/pages/{page_id}/duplicate
PUT /api/v1/design-templates/{tpl_id}/draft/pages/{page_id}
POST /api/v1/design-templates/{tpl_id}/draft/pages/{page_id}/move
DELETE /api/v1/design-templates/{tpl_id}/draft/pages/{page_id}
```
For example, this inserts an A4 landscape page after the cover:
```json
{
"name": "Services",
"format": "a4",
"orientation": "landscape",
"background_color": "#ffffff",
"after_page_id": ""
}
```
`POST` and `PUT` also accept optional `background_paint` for a full-page vector gradient. The shape is the same safe SVG paint object used by vector fills. For example, add this beside `background_color`:
```json
"background_paint": {
"type": "linear_gradient", "units": "object_bounding_box",
"x1": 0, "y1": 0.5, "x2": 1, "y2": 0.5,
"stops": [
{ "offset": 0, "color": "#123456", "opacity": 1 },
{ "offset": 1, "color": "#abcdef", "opacity": 1 }
]
}
```
`radial_gradient` is also supported. Use 2–32 ordered stops with offsets and opacities between 0 and 1. `background_color` stays as the underlay for translucent stops. Omit `background_paint` on an update to keep the current gradient. Set `clear_background_paint: true` to remove it and return to the solid `background_color`. The draft outline returns the active `background_paint`, and the same input is available through MCP page tools and AI Agent page capabilities.
Add `background_image` as an optional PNG, JPEG or WebP layer above the solid color or gradient. Transparent pixels and uncovered page areas show the base:
```json
"background_image": {
"src": "",
"fit_mode": "cover",
"focal_x": 0.75,
"focal_y": 0.4,
"scale": 1.25
}
```
`cover` fills the page and crops the image; `contain` shows the whole image; `stretch` fills without preserving aspect ratio. `scale` multiplies the fitted size (0.25–4, default 1). `focal_x` and `focal_y` align the scaled image within the page (`0` = left/top, `1` = right/bottom, default 0.5). The editor lets the user drag and scale the image in a page preview. On `PUT`, omit `background_image` to retain it, supply a new image to replace it, or set `clear_background_image: true` to remove it independently of the base paint. The editor uploads a local image through the DesignDocument image upload endpoint. REST, MCP and AI Agent page commands accept the uploaded path or an HTTPS image URL. The draft outline includes the active image settings. Use a Madoo storage path for templates that will be shared: stored background assets are listed in published revisions and copied with shared templates. An external HTTPS URL can render, but it must be imported into Madoo storage before sharing the template.
Named formats are `a3`, `a4`, `a5`, `letter`, `legal`, `tabloid` and `square`. Use `custom` with `width` and `height` in PDF points for another size. A document can contain 1–100 authored pages. The last page cannot be removed. A page referenced as a repeating region’s continuation prototype is protected until that reference is changed. Duplicate creates fresh stable IDs for the page, every element, placeholder and guide; it never aliases the source objects. `move` accepts a one-based `position`.
The corresponding MCP tool is `edit_design_template_page` with `operation` `add`, `duplicate`, `update` or `move`; a page is removed with `remove_design_template_item` (`kind` `page`), the one destructive template edit.
AI Agent exposes the same operations as `design_template.add_page`, `duplicate_page`, `update_page`, `move_page` and `remove_page`. It reads the compact outline and chains the returned ETag after every local edit. AI Assistant remains workflow-scoped and does not edit DesignDocuments.
### Reuse a master layout across pages
[Section titled “Reuse a master layout across pages”](#reuse-a-master-layout-across-pages)
A master page holds a shared header, footer, logo, decoration or background. Create it from an existing page size, add static elements using its `page_id`, then assign it to one or more output pages of exactly that size:
```http
POST /api/v1/design-templates/{tpl_id}/draft/master-pages
PUT /api/v1/design-templates/{tpl_id}/draft/master-pages/{master_page_id}
PUT /api/v1/design-templates/{tpl_id}/draft/pages/{page_id}/master
PUT /api/v1/design-templates/{tpl_id}/draft/master-pages/{master_page_id}/automatic-rule
POST /api/v1/design-templates/{tpl_id}/draft/pages/batch-master
DELETE /api/v1/design-templates/{tpl_id}/draft/master-pages/{master_page_id}
```
Create with `{ "name": "Brochure header", "size_from_page_id": "" }`. The creation response supplies the stable master `page_id`; use that ID to add text, shapes or images. Update its name, size or background with the same page-update body described above; changing its size is rejected while incompatible pages are attached. Assign with:
```json
{
"master_page_id": "",
"use_master_background": true,
"master_layer": "underlay"
}
```
Use `overlay` to place master artwork above local elements. Set `master_page_id` to `null` to detach. Deleting a master detaches its pages; it does not delete their local content. The draft outline exposes `master_pages`, each output page’s master assignment and the master element IDs. Up to 32 masters can be stored in one document; they are not counted as output pages.
Set `automatic_rule` on a master to `none`, `all`, `odd`, or `even` when creating it, or update it later through `/automatic-rule` with `{ "automatic_rule": "odd" }`. Only one master can use the same rule at a given page size; odd/even override all. New pages use automatic assignment. Existing pages retain their previous choice. The single-page `/master` body accepts `assignment`: `automatic`, `manual`, or `none`. A manual choice requires `master_page_id`; automatic and none require null. The latter two choices let a cover or back cover override the rules permanently. Automatic pages re-evaluate their master after reorder and on the final page number after repeated content expands.
To change several pages atomically, post `{ "scope": "all|odd|even|range", "start_page": 2, "end_page": 10, "assignment": "automatic|manual|none", "master_page_id": null, "use_master_background": true, "master_layer": "underlay" }` to `/batch-master`. The inclusive range fields are needed only for `range`. A manual master must match every selected page’s size; otherwise nothing is saved. The draft outline returns `automatic_rule` on masters and `master_assignment` on output pages.
Master artwork can contain static elements and page-number fields. Put data placeholders, conditions and repeated regions on output pages. For editable page labels, author a normal text element whose `text` contains complete tokens, for example `Page {{page}} of {{pages}}`. `{{page}}` is the current page’s number within its numbering sequence; `{{pages}}` is the document page count; `{{sequencePages}}` is the current sequence count. Values use the active Arabic or Roman numbering style and resolve after repeated content expands. Literal words remain editable. A hidden page renders the entire token-bearing text element empty. Unknown or incomplete tokens stay literal. The editor inserts localized default words, while the stored text keeps the author’s wording. The same `text` works through REST v1, MCP and the AI Agent.
Existing documents can continue to use `special_field_code` set to `page_number`, `page_label`, `document_page_count`, `sequence_page_count`, `page_document_slash`, `page_document_of`, `page_sequence_slash`, or `page_sequence_of`. These field-only elements retain ordinary text styling and can live on a page or master. Their displayed text is resolved after repeat expansion. The draft outline exposes each element’s `special_field_code`. The labelled codes (`page_label`, `page_document_of`, `page_sequence_of`) always print Italian words (“Pagina 1 di 3”), whatever the document’s language, so new text should use the tokens above. REST v1 still accepts `special_field_code` when adding text, for older clients; the MCP tool and the AI Agent no longer offer it.
Use `PUT /draft/pages/{page_id}` with the existing page settings and optional `numbering_start` (1–3000) plus `numbering_style` (`arabic`, `roman_upper`, `roman_lower`) to restart a sequence on that page. `clear_numbering_start` removes the marker. `hide_page_number` hides page fields on one page without interrupting its sequence; a common use is an unnumbered cover followed by a second page starting at 1. The marker follows its page on reorder. The outline reports these page properties. Document page counts include hidden pages; sequence counts stop at the next marker. MCP `add_design_template_text` and `edit_design_template_page` (operation `update`), and AI Agent `design_template.add_text` and `design_template.update_page`, accept the same optional properties. For a local Development qualification after restarting the stack, run `node docs/demo-workflows/design-template-render/qualify-page-fields-authoring-dev.mjs`. The gate writes through REST and MCP, checks the outline and canonical content, then extracts the resulting page numbers from the PDF. To exercise the AI Agent planner as well, set `MADOO_PAGE_FIELD_AGENT_GATE=true` and run `qualify-master-page-agent-dev.mjs` from the same directory. Master assignments persist through published revisions, sharing/import and PDF rendering. These commands require the current `If-Match` draft ETag and a new `Idempotency-Key` for each edit.
MCP provides `edit_design_template_master_page` with `operation` `create`, `assign`, `set_rule` or `batch_assign`, and `remove_design_template_item` (`kind` `master_page`). AI Agent provides `design_template.create_master_page`, `design_template.assign_master_page`, `design_template.set_master_rule`, `design_template.batch_assign_master` and `design_template.remove_master_page`. For a local Development gate after restarting the stack, run `node docs/demo-workflows/design-template-render/qualify-master-page-authoring-dev.mjs`. It tests REST v1 and authenticated MCP writes, then revokes its temporary key and deletes the draft. `install-master-page-brochure-dev.mjs` installs the published brochure template and workflow; `qualify-master-page-brochure-dev.mjs` runs it and checks final page numbering in the PDF plus its page images. The checked-in brochure template demonstrates an `all` master rule, a fixed manual cover exception, and an automatic interior page. `qualify-master-page-agent-dev.mjs` runs a live AI Agent planner turn and checks that it authors one shared master and assigns it to two pages; this gate uses planner credits and deletes its temporary Idea and draft.
## Add fixed artwork, vector lines and revise existing elements
[Section titled “Add fixed artwork, vector lines and revise existing elements”](#add-fixed-artwork-vector-lines-and-revise-existing-elements)
A fixed logo, photo, background or decorative asset is different from an image placeholder: its source is stored in the template and does not need render data.
```http
POST /api/v1/design-templates/{tpl_id}/draft/static-images
If-Match: "dd-draft-r4-..."
Idempotency-Key: brochure-brand-art-v1
Content-Type: application/json
{
"page_id": "",
"name": "Brand artwork",
"source": "https://cdn.example.com/brand.png",
"left": 48, "top": 80, "width": 180, "height": 90,
"fit_mode": "fit"
}
```
`source` accepts an HTTPS URL, a Madoo storage path or an image data URI. HTTP URLs, absolute filesystem paths and control characters are rejected. Use `fit`, `fill` or `stretch` for sizing. To create an empty image frame, send `"source":""` and `"frame_shape":"rectangle"`, `"ellipse"`, or a curated shape catalog ID. The frame stays editable while its image source is empty.
A divider, connector or arrow is a native vector line and remains vectorial in PDF:
```http
POST /api/v1/design-templates/{tpl_id}/draft/lines
If-Match: "dd-draft-r5-..."
Idempotency-Key: brochure-arrow-v1
Content-Type: application/json
{
"page_id": "", "name": "Next step",
"x1": 80, "y1": 310, "x2": 420, "y2": 310,
"stroke_paint": { "type": "solid", "color": "#2563eb" },
"stroke_width": 3, "stroke_opacity": 0.85,
"stroke_line_cap": "round", "stroke_line_join": "round",
"stroke_dash_array": [12, 8],
"start_marker": "circle", "end_marker": "arrow"
}
```
Use an empty `stroke_dash_array` for a solid line. Common presets are `[12, 8]` for dashed and `[1, 6]` for dotted. Caps are `butt`, `round`, or `square`; endpoint decorations are `none`, `arrow`, or `circle`.
A rectangle starts with four sharp corners. Each corner can then be rounded independently:
```http
POST /api/v1/design-templates/{tpl_id}/draft/rectangles
If-Match: "dd-draft-r6-..."
Idempotency-Key: brochure-panel-v1
Content-Type: application/json
{
"page_id": "", "name": "Feature panel",
"left": 80, "top": 120, "width": 280, "height": 160,
"corner_radius_top_left": 0,
"corner_radius_top_right": 24,
"corner_radius_bottom_right": 48,
"corner_radius_bottom_left": 12,
"fill_paint": { "type": "solid", "color": "#f8fafc" },
"stroke_paint": { "type": "solid", "color": "#334155" },
"stroke_width": 2
}
```
Corner radii use the document’s point coordinate system and must be between zero and half the shorter side. A zero remains a sharp corner. The same four optional fields are accepted by the element `PATCH`, so integrations can change one corner without replacing the others. MCP exposes `add_design_template_rectangle`; AI Agent exposes `design_template.add_rectangle`. The Editor’s live-corner widgets write the same fields.
### Built-in shape library
[Section titled “Built-in shape library”](#built-in-shape-library)
Madoo exposes a versioned catalog of built-in vector shapes. Read the catalog before authoring so the integration does not have to guess IDs or embed its own geometry:
```http
GET /api/v1/design-template-shapes
```
The response groups shapes into `basic`, `arrows`, `stars_badges`, `callouts`, and `flowchart`. Each item includes a stable `shape_id`, display name, search keywords, standard SVG `path_data`, a normalized view box, fill rule, provenance, and license. Catalog version `1.0.0` contains 25 Madoo-original shapes.
Add a selected shape to the draft with the latest ETag and a fresh retry key:
```http
POST /api/v1/design-templates/{tpl_id}/draft/shapes
If-Match: "dd-draft-r6-..."
Idempotency-Key: brochure-feature-star-v1
Content-Type: application/json
{
"page_id": "",
"shape_id": "star_5",
"left": 80, "top": 120, "width": 120, "height": 120,
"fill_paint": { "type": "solid", "color": "#f97316" },
"stroke_paint": { "type": "solid", "color": "#7c2d12" },
"stroke_width": 2
}
```
The operation copies the standard SVG path into a normal `PathElement`. The saved document, its revisions, shared imports, and PDFs therefore remain independent of later catalog changes. MCP exposes `list_design_template_shapes` and `add_design_template_shape`; AI Agent exposes `design_template.list_shapes` and `design_template.add_shape`. Editor, REST v1, MCP, and AI Agent all use the same catalog and canonical vector representation.
Custom vector artwork uses the standard SVG `d` syntax rather than Fabric JSON or a Madoo-specific path language:
```http
POST /api/v1/design-templates/{tpl_id}/draft/svg-paths
If-Match: "dd-draft-r6-..."
Idempotency-Key: brochure-feature-star-v1
Content-Type: application/json
{
"page_id": "", "name": "Feature star",
"path_data": "M 60 0 L 74 42 L 120 42 L 82 68 L 96 112 L 60 84 L 24 112 L 38 68 L 0 42 L 46 42 Z",
"left": 80, "top": 120, "width": 120, "height": 112,
"fill_paint": {
"type": "linear_gradient", "units": "object_bounding_box", "spread_method": "pad",
"x1": 0, "y1": 0, "x2": 1, "y2": 1,
"gradient_transform": [],
"stops": [
{ "offset": 0, "color": "#f97316", "opacity": 1 },
{ "offset": 1, "color": "#7c3aed", "opacity": 1 }
]
},
"fill_rule": "non_zero"
}
```
The bounded safe profile accepts SVG commands `M/L/H/V/C/S/Q/T/A/Z`, including relative commands. Fill and stroke paint can be `none`, `solid`, `linear_gradient`, or `radial_gradient`, with 2–32 ordered stops and an optional six-value SVG `gradient_transform`. Gradient stroke stops must be opaque; fill-stop transparency is preserved in the vector PDF through a soft mask. `paint_order` chooses `fill_stroke` or `stroke_fill`; `non_scaling_stroke` keeps the visual stroke width during proportional resizing. Unsupported combinations fail validation instead of producing a visually different PDF.
Every gradient stop accepts an exact `offset` from 0 to 1 and a `#RRGGBB` color. Fill stops additionally accept `opacity` from 0 to 1. Linear geometry uses `x1`, `y1`, `x2` and `y2`; radial geometry uses `cx`, `cy`, `r` and optional `fx`, `fy`, `fr`. Coordinates are fractions when `units` is `object_bounding_box` and local document units when it is `user_space_on_use`. The Editor exposes the same data as draggable stops, exact color, position and opacity fields, linear angle, and radial center, focus and radius controls. Changing gradient kind preserves existing stops and any imported `gradient_transform`.
Complete SVG clipart or multi-path artwork can be imported in one operation:
```http
POST /api/v1/design-templates/{tpl_id}/draft/svg-imports
If-Match: "dd-draft-r7-..."
Idempotency-Key: brochure-brand-clipart-v1
Content-Type: application/json
{
"page_id": "",
"name": "Brand clipart",
"svg": "",
"left": 80, "top": 120, "width": 240, "height": 120,
"mode": "preserved"
}
```
`mode` accepts `editable` (the default) or `preserved`. Editable import converts the supported SVG subset into normal Madoo groups, paths and text for deep editing. Preserved import keeps complex passive artwork as one atomic vector element. Its source is an immutable managed asset and its palette remains editable and resettable. Both modes reject active and external content and neither silently rasterizes the file.
The importer accepts at most 10 MB of UTF-8 SVG, 500 drawable objects and 16 levels of source nesting. It converts paths, rectangles, circles, ellipses, lines, polylines, polygons and groups into normal canonical groups and paths. It supports local paints, gradient transforms, fill and clip rules, gradient stroke, paint order, non-scaling stroke, and affine transform matrices including skew. A bounded embedded-CSS subset supports simple element, `.class`, `#id`, and compound selectors for the same portable visual properties; inline style keeps normal cascade precedence. CSS imports, at-rules, combinators, pseudo-classes, external URLs, and unsupported visual properties are rejected. Local `href`/`xlink:href` inheritance between gradient definitions and bounded local `use` references are resolved before conversion. Vector clip paths, bounded alpha or luminance masks, endpoint markers, and simple single-run SVG text become canonical DesignDocument data. Non-rendering editor metadata is ignored. External legacy DOCTYPE declarations are ignored with resolution disabled; DTD entities remain invalid. The source XML and CSS are discarded after conversion. Scripts, event handlers, external references, embedded SVG images, arbitrary filters, marker-mid, `tspan`, text paths and other unsupported constructs fail with a specific `svg_import_*` error. They are never silently rasterized or simplified.
Portable colors include named colors, `#RGB`, `#RRGGBB`, integer `rgb(0, 128, 255)` and decimal percentage `rgb(0%, 50.2%, 100%)` notation. Percentage channels must remain between 0 and 100 and are normalized to `#RRGGBB`; paint opacity stays in its explicit SVG opacity field.
MCP exposes `import_design_template_svg`; AI Agent exposes `design_template.import_svg`. Both accept the same `mode`. The Editor asks which mode to use when an SVG is selected through the Image tool. Revisions and sharing carry either canonical geometry or the immutable preserved asset. Both PDF paths remain vectorial.
Preserved mode accepts at most 10 MB of UTF-8 SVG, 2,000 elements and 32 levels of nesting. It flattens the safe CSS subset, rejects scripts, event handlers, external files, network references and embedded raster data, and runs the actual PDF graphics preflight before storing the content-addressed asset. A saved `svg_artwork` records the source hash, sanitizer profile, viewBox and bounded paint-token palette. Palette changes modify only the token overrides; they do not rewrite the source geometry.
An imported canonical artwork keeps an editable palette after import. Selecting its group in the Editor opens **Colors**, which lists each original color, its current value and its use count. A color can be changed throughout the artwork and later restored on its own; **Reset all** restores the complete import-time palette. The replacement covers solid fills, outlines, gradient stops, simple SVG text and endpoint decorations while preserving geometry, opacity, gradient coordinates and transforms. It is a normal draft edit, so it survives save/reopen, revisions, sharing and PDF rendering.
The original value is stored beside every canonical paint occurrence rather than inferred from the current palette. If two original colors are both changed to the same target, either one can therefore still be reset independently. Documents created before this metadata existed capture their current palette as the baseline on their first palette edit.
REST v1 exposes the same semantic operation without requiring the full document body:
```http
GET /api/v1/design-templates/{tpl_id}/draft/elements/{element_id}/palette
POST /api/v1/design-templates/{tpl_id}/draft/elements/{element_id}/palette-replacements
If-Match: "dd-draft-r8-..."
Idempotency-Key: recolor-brand-artwork-001
Content-Type: application/json
{ "source_color": "#f97316", "target_color": "#22c55e" }
```
`source_color` identifies the immutable original palette token returned by the read. The read response returns `original_color`, current `color`, `uses`, and `modified` for every editable paint in the selected element subtree. Restore one token, or omit `original_color` to restore the complete palette:
```http
POST /api/v1/design-templates/{tpl_id}/draft/elements/{element_id}/palette-resets
If-Match: "dd-draft-r9-..."
Idempotency-Key: reset-brand-artwork-001
Content-Type: application/json
{ "original_color": "#f97316" }
```
MCP reads the palette with `get_design_template` (`view` `palette`, `element_id`) and changes it with `edit_design_template_palette` (`operation` `replace_color`, `reset_color` or `reset_all`); AI Agent exposes `design_template.get_element_palette`, `design_template.replace_palette_color`, `design_template.reset_palette_color`, and `design_template.reset_palette`. All authoring surfaces call the same bounded Application palette logic.
Rectangle, circle, ellipse and SVG path use the same portable fill contract. Their `fill_paint` can be `none`, `solid`, `linear_gradient` or `radial_gradient`, with a separate `fill_opacity`. Rectangle, circle, ellipse, line and SVG path also use the same stroke fields: `stroke_paint` (`none`, `solid`, `linear_gradient`, or `radial_gradient`), `stroke_width`, `stroke_opacity`, `stroke_line_cap`, `stroke_line_join`, `stroke_dash_array`, `stroke_dash_offset` and `stroke_miter_limit` and `non_scaling_stroke`. Closed vectors also accept `paint_order`. The earlier `fill` and `stroke` color strings remain accepted when typed paint is absent, so previously saved templates do not need a migration.
Existing elements can be changed or removed locally:
```http
PATCH /api/v1/design-templates/{tpl_id}/draft/elements/{element_id}
DELETE /api/v1/design-templates/{tpl_id}/draft/elements/{element_id}
```
The patch accepts common geometry and state fields (`name`, `left`, `top`, `width`, `height`, `angle`, `opacity`, `visible`, `locked`, `flip_x`, `flip_y`). The flip fields mirror an element within its authored bounding box and are preserved in editor, preview, rendered pages and PDF. `target_page_id` moves a top-level element to another page. Text elements additionally accept `text`, font family/size/ weight/style, color, alignment, line height, underline, strikethrough and bounded overflow settings. Image elements accept `image_source` and `fit_mode`. A vector image frame uses `frame_shape` (rectangle, ellipse, or a shape catalog ID) or `frame_path_data` (a closed standard SVG path in image-local PDF points). Optional `frame_focal_x`, `frame_focal_y` (0–1), `frame_scale` (0.01–4), `frame_offset_x`, `frame_offset_y` (image-local points, bounded to four times the image size), and `frame_rotation` (degrees, -360 to 360) place the source independently inside the clipped frame. The offset remains effective even when the source and contour have identical dimensions. The editor may lower the stored frame scale while enlarging the mask viewport so the photo keeps the same visible size and page position. `frame_stroke_color` and `frame_stroke_width` draw an inside border. `clear_frame` releases the frame. `mask_shape` or `mask_path_data` adds a vector opacity mask in the same local coordinate system, with optional `mask_opacity` (0–1); `clear_mask` removes it. The image frame and mask travel with the normal element through revisions, published versions, sharing/import, preview and PDF. Closed vector elements accept the shared fill fields, while every vector element accepts the shared stroke fields; lines additionally accept start/end decorations. Rectangles accept `corner_radius_top_left`, `corner_radius_top_right`, `corner_radius_bottom_right`, and `corner_radius_bottom_left`. Every drawable element accepts a `shadows` array containing zero or one outer shadow; semantic containers and groups do not. The shadow has `color`, `opacity`, `offset_x`, `offset_y`, `blur`, `spread`, and `non_scaling`; send an empty array to remove it. Coordinates and sizes use the same PDF-point space as the element, and the shadow is retained by draft revisions, published versions, sharing/import, page rendering and PDF export. Type-specific fields on the wrong element type fail before saving. An SVG path additionally accepts `path_data` on the same update operation, using the standard safe `d` grammar (`M/L/H/V/C/S/Q/T/A/Z`, including relative forms). This lets REST, MCP and AI Agent reshape artwork created in the Editor without replacing the document. Nested elements can be edited or removed in place; moving one across pages requires moving its containing region.
MCP uses `add_design_template_static_image`, `add_design_template_line`, `add_design_template_rectangle`, `add_design_template_svg_path`, `import_design_template_svg`, `update_design_template_element` and `remove_design_template_item` (`kind` `element`). AI Agent uses the parallel `design_template.add_static_image`, `add_line`, `add_rectangle`, `add_svg_path`, `import_svg`, `update_element`, `update_image` and `remove_element` capabilities. Its text/common update and image-specific update are separate so the planner cannot accidentally apply image defaults to a text box. All three surfaces call the same Application commands, workspace checks, ETag compare, transactional receipt and content validator.
## Add one text element to a native draft
[Section titled “Add one text element to a native draft”](#add-one-text-element-to-a-native-draft)
Use `design-templates:write` and `WsAssetsManage`. Read the outline for the current `draft_etag` and an optional page ID, then add a named text element using PDF points. A blank A4 draft is about 595 × 842 points. This command modifies just the requested element; it does not accept a replacement document body.
```http
POST /api/v1/design-templates/{tpl_id}/draft/text-elements
If-Match: "dd-draft-r1-..."
Idempotency-Key: proposal-title-001
Content-Type: application/json
{ "name": "Proposal title", "text": "Proposal for Acme", "left": 36,
"top": 42, "width": 500, "height": 60 }
```
The response returns `element_id`, the new `draft_revision` and `draft_etag`. A stale ETag returns `412 precondition_failed`. Retrying the same body/key returns the original result and `Idempotency-Replayed: true` even when the draft has since advanced; changed body with the same key returns `409 idempotency_conflict`. The semantic body, not the `If-Match` header, defines the retry payload. Migration 479 stores the receipt in the same SQL transaction as the draft revision and placeholder index. The Editor continues to use its existing DesignDocument content save path and sees this added element on reload.
MCP `add_design_template_text` uses the same command and contract. It requires `if_match` and `idempotency_key`, applies the same workspace/scope/permission guard and records a write audit.
### Mixed styles inside one text element
[Section titled “Mixed styles inside one text element”](#mixed-styles-inside-one-text-element)
`POST /draft/text-elements` and `PATCH /draft/elements/{element_id}` accept optional `rich_text` in the renderer-neutral `madoo.rich-text/v1` format. It contains paragraphs and contiguous character runs; it never contains HTML, editor selection offsets or Fabric objects. Each null run property inherits from the owning text element.
```json
{
"name": "Proposal title",
"left": 36, "top": 42, "width": 500, "height": 80,
"rich_text": {
"schema": "madoo.rich-text/v1",
"paragraphs": [
{ "list": { "kind": "ordered", "level": 0 }, "runs": [
{ "text": "Proposal for ", "fontWeight": "bold" },
{ "text": "Acme", "fontStyle": "italic", "fill": "#2563eb" }
] },
{ "list": { "kind": "ordered", "level": 0 }, "runs": [ { "text": "Hackathon brief", "underline": true } ] }
]
}
}
```
The Application service derives the compatible `TextElement.Text` projection by joining runs and paragraphs with `\n`. When a client also supplies `text`, it must equal that projection. An LF inside a run is a soft break belonging to the same paragraph/list item (the editor authors it with Shift+Enter); a paragraph boundary is a hard break and starts the next item. CR characters are invalid. The bounded format allows 1–1000 paragraphs, at least one run per paragraph, at most 10,000 runs and 10,000 projected characters. A paragraph can carry optional list semantics: `"list":{"kind":"bullet|ordered","level":0,"start":1}`. `level` is 0-8; `start` is allowed only on an ordered paragraph and restarts that level between 1 and 1,000,000. Markers are generated by the editor and PDF renderer and never become part of `TextElement.Text`, run text, selection offsets or placeholder values. A run can override `fontFamily`, `fontSize`, `fontWeight`, `fontStyle`, exact portable `font`, `fill`, `underline`, `linethrough` and `baselineShift` (positive values move upward). Rich text upgrades the draft to DesignDocument schema 2.0 and participates in revisions, sharing/import, font manifests, masters/components, previews and PDF rendering.
On `PATCH`, sending plain `text` deliberately clears prior inline formatting. Send `clear_rich_text: true` to remove inline formatting while retaining the existing plain projection. `rich_text` and `clear_rich_text` cannot be combined. MCP `add_design_template_text` and `update_design_template_element`, plus Agent `design_template.add_text` and `design_template.update_element`, use the same canonical object, validation, ETag and idempotency rules. Lists are paragraph semantics; columns will belong to the frame; linked frames will reference one shared text story rather than duplicate the run content.
To make that text dynamic, read its `element_id` from the outline and configure a text field:
```http
PUT /api/v1/design-templates/{tpl_id}/draft/text-elements/{element_id}/placeholder
If-Match: "dd-draft-r2-..."
Idempotency-Key: proposal-headline-field-001
Content-Type: application/json
{ "code": "headline", "name": "Proposal headline", "required": true,
"description": "The title supplied at render time" }
```
This creates or updates the placeholder on just that element. The code becomes a key in the published revision’s `fields` contract and in the `design/template_render` JSON input, for example `{ "headline": "Proposal for Acme" }`. Use a new retry key for this command. The same ETag/retry and workspace rules apply; the Editor sees the binding on reload. MCP `configure_design_template_element_placeholder` with `type` `text` uses the same service and response.
For an element already present in a native draft, the typed variant can bind any compatible field. Read the outline for its element ID and current ETag, then use a fresh retry key:
```http
PUT /api/v1/design-templates/{tpl_id}/draft/elements/{element_id}/placeholder
If-Match: "dd-draft-r5-..."
Idempotency-Key: proposal-budget-field-001
Content-Type: application/json
{ "type": "number", "code": "budget", "name": "Campaign budget",
"required": true, "description": "Amount supplied as a JSON number" }
```
The field types follow the DesignDocument renderer’s compatibility rules:
| Field type | Compatible existing element | Example `values` JSON |
| ---------------- | --------------------------- | ------------------------------------------ |
| `text`, `number` | Text | `"headline": "Proposal"`, `"budget": 1200` |
| `boolean` | Text or container | `"approved": true` |
| `image` | Image | `"hero": "https://example.com/hero.jpg"` |
| `color` | Text or compatible shape | `"accent": "#2255AA"` |
| `json` | Repeat region | `"items": [{"name":"A"}]` |
A repeat region’s JSON source code is updated with the field code. An incompatible element/type, invalid code or wrong-type default returns 400 before a draft write. The same ETag/retry behavior applies as for text fields. MCP `configure_design_template_element_placeholder` calls the same command and returns the same result. This command binds existing elements.
## Create a dynamic image box
[Section titled “Create a dynamic image box”](#create-a-dynamic-image-box)
One command places an image box and defines its required image field together:
```http
POST /api/v1/design-templates/{tpl_id}/draft/image-placeholders
If-Match: "dd-draft-r3-..."
Idempotency-Key: proposal-hero-image-001
Content-Type: application/json
{"name":"Hero image","code":"hero_image","left":48,"top":80,
"width":495,"height":260,"fit_mode":"fill"}
```
Dimensions are PDF points within the selected page; `page_id` is optional and defaults to the first page. `fit` shows the whole image, `fill` crops it to cover the box, and `stretch` changes its proportions. The field code becomes a key in sample `values` and `design/template_render.data`, for example `{"hero_image":"data:image/png;base64,..."}` or an image storage URI. The placeholder is required, so add a sample image and inspect its exact preview before publish. MCP `add_design_template_image_placeholder` calls the same Application command with the same permission, ETag and retry receipt.
## Create a simple repeated text list
[Section titled “Create a simple repeated text list”](#create-a-simple-repeated-text-list)
This one command creates a native repeat region, its JSON array field and a text row bound to one property of each item. It edits only that region; no full DesignDocument JSON is sent to the API:
```http
POST /api/v1/design-templates/{tpl_id}/draft/repeating-text-lists
If-Match: "dd-draft-r3-..."
Idempotency-Key: proposal-items-list-001
Content-Type: application/json
{"name":"Proposal line items","source_code":"items","item_field":"name",
"left":48,"top":120,"width":495,"height":240,"item_height":32,
"max_items":10,"overflow_policy":"fail"}
```
`source_code` is the JSON key supplied to `design/template_render.data`; `item_field` is the property read from each array item. For example, `{"items":[{"name":"Discovery"},{"name":"Delivery"}]}` draws two rows. The list area and row height use PDF points. `max_items` bounds expansion and `overflow_policy` chooses `fail`, `fit`, `clip` or `continue_page` when rows do not fit (`continue_page` adds copies of the page; only one such list per page, placed directly on it). The `design/template_render` node keeps this rule by default (`overflow_policy` `template`); setting the node to `fail`, `fit` or `clip` applies that rule to every Repeat of the template instead. Read the outline for its page ID and current ETag, then preview a sample before publishing. MCP `add_design_template_repeating_text_list` uses the same Application command, permission, retry receipt and response. The Development qualifier `qualify-dd7-repeating-list-dev.mjs` checks two rendered rows and a page JPEG; `repeating-line-items.json` is the workflow example with PDF, page image and layout report.
### Rows with several fields: cards, tables, galleries
[Section titled “Rows with several fields: cards, tables, galleries”](#rows-with-several-fields-cards-tables-galleries)
A row is not limited to one text: like in the editor it can hold texts, images, shapes and Layouts — a product card with photo, name, price and a “sold out” badge. Build it with the same commands used on a page, adding `parent_id`:
1. **Add elements into the row.** Every add command (`text-elements`, `image-placeholders`, `rectangles`, `lines`, `shapes`, `svg-paths`, `svg-imports`, `static-images`) accepts `"parent_id": ""`; coordinates are then relative to the row. `parent_id` also places an element inside a `layout_region`, a group or a layer folder.
2. **Bind them to the keys of each item.** A field bound inside a row — with `PUT /draft/text-elements/{id}/placeholder`, `PUT /draft/elements/{id}/placeholder` or an image box added with `parent_id` — is a key of each list item: the outline shows `binding_path: "item.price"` and the data is `{"products":[{"name":"Lampada","price":89,"photo":"…","sold_out":false}]}`. A number key keeps its `format`.
3. **Arrange the row** with `POST /draft/layouts/arrange` (the elements share the row as parent), and set what sits on top with the element update’s `z_order` (`front`, `back`, `forward`, `backward`): a card background added after its texts goes `back`.
4. **Show an element only when a key says so** with the element update’s `condition`: `{"condition":{"operator":"equals","placeholder_code":"item.sold_out","literal":"true"}}`. Operators: `not_empty`, `equals`, `greater_than`, `greater_than_or_equal`, `less_than`, `less_than_or_equal` (numeric literal), `not` (one condition), `all` / `any` (`conditions`); a code outside a row is a template field. `clear_condition` removes it.
5. **Configure the Repeat** — the editor’s Repeat panel:
```http
PATCH /api/v1/design-templates/{tpl_id}/draft/repeats/{repeat_id}
If-Match: "dd-draft-r7-..."
Idempotency-Key: catalog-grid-001
{"list_key":"products","max_items":9,"overflow_policy":"continue_page",
"layout":{"mode":"grid","columns":3,"row_gap":16,"column_gap":12},
"item_fields":[{"code":"sold_out","name":"Sold out","type":"boolean"}]}
```
`list_key` renames the list everywhere it is named (its field, conditions, sample sets); `layout` lays the rows out (`vertical`, `horizontal`, `grid` with `columns`, gaps, padding, alignments); `item_fields` declares the keys a condition reads but the row does not print. MCP `configure_design_template_repeat` and the agent’s `design_template.configure_repeat` run the same command. The outline reports a Repeat’s `list_key`, `max_items`, `overflow_policy`, `item_fields`, and every element’s position, size, `placeholder_type`, `binding_path`, `format` and `condition`.
Two more element properties, on the same `PATCH /draft/elements/{element_id}` (MCP `update_design_template_element`, agent `design_template.update_element`):
* **`char_spacing`** — letter spacing of a text, in thousandths of an em (`100` = a tenth of the font size; `300` for a spaced-out small-caps label).
* **`follows_row_height`** — in a Repeat row whose texts grow: `true` makes a rectangle, ellipse, image or line stretch with the row (a card background), `false` keeps its size; `clear_follows_row_height` returns to the automatic rule (full-height rectangles and lines stretch).
## Whole documents: read, write, create and duplicate
[Section titled “Whole documents: read, write, create and duplicate”](#whole-documents-read-write-create-and-duplicate)
Everything above edits a draft one command at a time. A template is also one JSON document, in the public format **`madoo.design-document/2.0`** — the format the editor saves, so everything the editor can author can be written here. Its JSON Schema is served by the schema catalog: `GET /api/v1/json-schemas/madoo.design-document/2.0` (MCP `get_json_schema`).
```http
GET /api/v1/design-templates/{tpl_id}/draft/content # the draft; ETag header
GET /api/v1/design-templates/{tpl_id}/versions/{revision}/content # a published revision, as published
PUT /api/v1/design-templates/{tpl_id}/draft/content # If-Match + Idempotency-Key; body = the document
POST /api/v1/design-templates # {"name":"…","content":{…}} starts the draft as that document
POST /api/v1/design-templates/{tpl_id}/duplicate # {"name":"…","revision":2} copies a revision (or the draft)
```
* **The format.** `schemaVersion` is `"2.0"`. Pages, master pages, components and container children hold elements discriminated by `$type`: `text`, `image`, `rectangle`, `circle`, `ellipse`, `line`, `path`, `group`, `layout_region`, `repeat_region`, `svg_artwork`, `component_instance`, `folder`. IDs are GUIDs unique in the document; a field is a `placeholder` on its element (`code`, `placeholderType`, `bindingPath` `item.key` inside a Repeat row, `format`). Read an existing template to see a complete example: it is the quickest way to learn the format.
* **Validation.** The document goes through the editor’s validation and save. Unknown properties are errors (a misspelt name would otherwise be lost), as are duplicate IDs, invalid field codes, bindings, conditions and limits. A rejected document returns **422 `content_invalid`** with every problem in `errors` (`field` = JSON path, `message` = code and explanation).
* **Concurrency and retries.** `PUT` requires the draft ETag as `If-Match` and an `Idempotency-Key`, like every draft edit; `POST` requires an `Idempotency-Key`.
* **When to use it.** Build or rewrite a whole template, copy an example and adapt it, move a template between workspaces (fonts and storage images must exist in the target workspace). For small changes the element commands above are safer: they cannot touch what they do not name.
**Sample sets.** A document sent without a `sampleSets` property keeps the draft’s sample sets (an empty list clears them). REST v1 returns them in full; MCP (`get_design_template` view `content`) and the agent (`design_template.get_content`) leave them out unless `include_sample_sets` is true and list them in `omitted_sample_sets` (ID, name, field codes): they are example values, often with inline images, and can weigh most of a template (F05: 180,000 of 255,000 characters). Reading the draft without them and sending it back therefore never loses them.
**One page of the outline.** MCP `get_design_template` view `outline` and the agent’s `design_template.get_draft_outline` take `page_id` (a page or master page) to list only that page’s elements; every page, master page, sample set, style and component stays listed.
MCP: `get_design_template` (view `content`), `replace_design_template_content`, `duplicate_design_template`, `create_design_template_draft` with `content`. Agent: `design_template.get_content`, `design_template.replace_content`, `design_template.duplicate`, `design_template.create_draft` with `content`. The template list (`GET /api/v1/design-templates`, MCP `list_design_templates`, agent `design_template.list`) takes `status` = `published` (default), `draft`, `archived` or `all`.
## Exercise the template with example JSON
[Section titled “Exercise the template with example JSON”](#exercise-the-template-with-example-json)
Read the current draft outline for its ETag and the placeholder codes, then add a named sample set. Its `values` object uses those codes as keys, exactly like the `design/template_render` `data` input. For a required text field called `headline`:
```http
POST /api/v1/design-templates/{tpl_id}/draft/sample-sets
If-Match: "dd-draft-r3-..."
Idempotency-Key: proposal-headline-sample-001
Content-Type: application/json
{ "name": "Proposal authoring example",
"values": { "headline": "Proposal for Acme Studio" } }
```
The response supplies `sample_set_id` and the new draft ETag. The outline lists the sample by name and covered field codes. Unknown codes or values of the wrong JSON type return 400. A stale ETag returns 412; the same body/key replays without another draft revision, while a changed body with that key returns 409. Use a different retry key for each sample. MCP `edit_design_template_sample_set` (`operation` `add`) uses the same Application command, scope and audit rules.
When a new required field is added later, an earlier sample may become unready. Extend that specific sample without replacing the document or losing its other values:
```http
PATCH /api/v1/design-templates/{tpl_id}/draft/sample-sets/{sample_set_id}/values
If-Match: "dd-draft-r6-..."
Idempotency-Key: proposal-example-add-budget-001
Content-Type: application/json
{ "values": { "budget": 1200 } }
```
This merges just `budget` into the selected sample. Read its ID and ETag from the outline. Unknown codes or wrong JSON types return 400; a missing sample returns 404, stale ETag 412, same-key replay returns the original revision, and changed payload/key returns 409. MCP `edit_design_template_sample_set` (`operation` `set_values`) uses the same command and write audit. Existing values such as `headline` remain in the sample; the next readiness check evaluates all samples again.
An exact preview is a read operation using `design-templates:read` and `WsAssetsRead`:
```http
GET /api/v1/design-templates/{tpl_id}/draft/sample-sets/{sample_set_id}/exact-preview?max_page_dimension=900
```
It returns page JPEG `data_uri` values, a compact layout report and readability/readiness checks for the current draft. The page size can be 400–1600 pixels. MCP `preview_design_template_sample_set` returns the same projection. Preview renders the sample with the DesignDocument engine and makes no AI provider call. Check the pages and fix blocking diagnostics before publication. The readiness response reports whether required fields remain unexercised by any sample.
## Update a saved workflow pin after review
[Section titled “Update a saved workflow pin after review”](#update-a-saved-workflow-pin-after-review)
The default workflow pin stays at the published revision selected during authoring. The `latest_compatible` policy may use a newer compatible revision in one new run, but that run never edits the saved workflow. To promote a saved pin, review the affected workflow first:
```http
GET /api/v1/workflows/{wf_id}/template-revisions
```
This response shows each renderer’s saved/latest revision, field and page changes, compatibility issue and the definition ETag. Choose only nodes marked compatible. Then explicitly save the chosen revision with the ETag from that review:
```http
POST /api/v1/workflows/{wf_id}/template-revisions/upgrade
If-Match: ""
Content-Type: application/json
{"nodes":[{"node_id":"render_proposal","target_revision":2}]}
```
One request may select 1–20 distinct nodes. The command changes only their published template pins, preserves connections and editor layout, validates the whole resulting workflow and returns the new workflow version/ETag. A Published workflow gets a new copy-on-write definition version; an Archived workflow cannot be edited. An old ETag returns 412; an unreviewed or incompatible revision returns 409. Review again after either conflict. MCP `get_workflow_template_revisions` and `upgrade_workflow_template_revision` provide the same read/write sequence for one node, and AI Agent exposes `workflow.review_template_revisions` followed by `workflow.upgrade_template_revision`. The Editor offers a local revision diff before the author saves its canvas. When AI Assistant requests the same read-only review, its chat panel shows a card with a fresh server review, one-node selection and confirmation. Only the author’s click invokes the REST write with ETag; the card requires the reviewed workflow to be open in Editor with no unsaved changes.
The Development example `DD7 Persistent Pin Upgrade — Isolated Proposal` uses the compatible two-revision fixture and checks this operation without creating an execution or changing the original two-page proposal workflow.
## Render a published revision directly as PDF
[Section titled “Render a published revision directly as PDF”](#render-a-published-revision-directly-as-pdf)
DD9 applies shared admission to direct PDF render, exact preview and workflow render. A request exceeding configured data-set, aggregate output-page or resolved-element limits fails with `DESIGN_RENDER_LIMIT_EXCEEDED`; an oversized image fails with `DESIGN_IMAGE_LIMIT_EXCEEDED`, an excessive total of materialized images with `DESIGN_IMAGE_TOTAL_LIMIT_EXCEEDED`, an image exceeding the pixel budget with `DESIGN_IMAGE_PIXEL_LIMIT_EXCEEDED`, an oversized native content JSON with `DESIGN_CONTENT_INPUT_LIMIT_EXCEEDED`, and an oversized imported PDF with `DESIGN_PDF_INPUT_LIMIT_EXCEEDED`. A native draft whose stored JSON differs from its recorded SHA-256 fails with `CONTENT_HASH_MISMATCH`; a published version uses `VERSION_HASH_MISMATCH`. PDF sections already exceeding the output budget are rejected before rendering; a final PDF exceeding it fails with `DESIGN_PDF_OUTPUT_LIMIT_EXCEEDED`. The limits are deployment configuration (`DesignTemplates:RenderLimits`), not client-supplied fields. Initial defaults are 100 data sets, 200 output pages, 25,000 resolved elements, 20 MiB per image, 100 MiB total images, 40 million pixels per image, 32 MiB per native content JSON, 100 MiB per imported PDF and 100 MiB per output PDF. The final-byte cap is checked after PDF generation; file-backed native outputs are deleted on rejection.
The resolver checks workspace access before every cache lookup. Verified, content-addressed JSON is cached per organization, workspace, path and SHA-256 with a 128 MiB process-local budget and 10-minute absolute TTL by default. Content without a recorded hash is read within the byte limit but is not cached. Concurrent requests for the same content share one download per process; a hash mismatch is rejected and never cached.
`POST /api/design-documents/import-pdf` applies `MaxImportedPdfBytes` before multipart buffering and PDF analysis. The default is 100 MiB; an oversized upload returns HTTP 413 with `DESIGN_PDF_INPUT_LIMIT_EXCEEDED` and creates no document. The request body may use up to 1 MiB extra for the multipart envelope.
For imported PDFs with multiple data sets, the renderer also stops before merge when the cumulative byte size of generated iterations exceeds `MaxOutputPdfBytes`. This is a conservative bound: it may reject an input whose merged PDF would have deduplicated some bytes. The error remains `DESIGN_PDF_OUTPUT_LIMIT_EXCEEDED`.
DD9 render admission also has process-local pools: two simultaneous interactive preview/direct export renders and four workflow renders by default. A slot covers the PDF render and every page image rasterized from it (exact preview, `pages`/`image` outputs, REST v1 and MCP page renders). Requests beyond the slots wait in a bounded first-in-first-out queue (32 interactive, 64 workflow) for at most 20 seconds (interactive) or 60 seconds (workflow). When the queue is full or the wait expires the caller receives `DESIGN_RENDER_BUSY`: HTTP 503 with `Retry-After: 5` on direct REST routes (REST v1 included), a retryable node failure in workflows. Cancellation while waiting aborts immediately. Exporting an imported PDF that has no image replacement returns the unchanged original without taking a slot. All values are configurable under `DesignTemplates:RenderLimits`.
### Workspace ICC profiles and PDF/X-4
[Section titled “Workspace ICC profiles and PDF/X-4”](#workspace-icc-profiles-and-pdfx-4)
The editor’s PDF export and the `design/template_render` node can produce an ordinary PDF or a print-ready PDF/X-4 that embeds an ICC profile uploaded by the user as its OutputIntent. The catalog belongs to the workspace and can hold several profiles:
```http
GET /api/v1/print-color-profiles
```
The upload is multipart and requires a printer-class CMYK ICC profile, version 2 or 4, of at most 5 MB:
```http
POST /api/v1/print-color-profiles
Content-Type: multipart/form-data
displayName=PSO Coated v3 — Printer A
outputConditionIdentifier=FOGRA51
setAsDefault=true
licenseAcknowledged=true
file=@printer-a.icc
```
The file is immutable and identified by `profileGuid` and `sha256`. Change the default with `POST /api/v1/print-color-profiles/{profileGuid}/default`, or archive a profile with `POST /api/v1/print-color-profiles/{profileGuid}/archive`. Archiving removes it from new selections but does not invalidate published workflows that already pinned it.
The editor endpoint `POST /api/design-documents/{id}/generate-pdf` accepts, besides the placeholder values:
```json
{"exportMode":"pdfx4","colorProfileGuid":"11111111-1111-4111-8111-111111111111"}
```
`exportMode` is `standard` by default. On the `design/template_render` node the equivalents are `pdf_export_mode=pdfx4` and `color_profile_guid`; on publish Madoo adds the private fingerprint `colorProfileSha256`. The `layout_report` records the mode and the identity of the profile used.
This first version keeps the authored colours as managed RGB (sRGB by default) and uses the CMYK profile as the print destination (output intent). It offers no soft proof, spot colours, overprint, trapping, total-ink control or per-image source conversion. Imported PDFs and intro/outro PDFs cannot take the PDF/X-4 path; use `standard` or a native DesignDocument. The profile must match the printer’s specifications, and a clone in another workspace must select or upload an authorised profile there.
Read the published revision contract for its exact field codes and JSON types. Send that JSON object as the request body:
```http
POST /api/v1/design-templates/{tpl_id}/versions/{revision}/render-pdf
Content-Type: application/json
{"customer":"Example Customer","headline":"Example Proposal"}
```
The response is a PDF download and carries `X-Design-Template-Version: tplv_...` and `X-Design-Template-Pages`. This read-only render uses the same production template renderer as the workflow node. Unknown codes, wrong JSON types, duplicate codes and missing required fields fail before rendering; the body is limited to 1 MB/200 fields and the direct result to 20 MB/100 pages. Use a workflow for larger or repeated production outputs. MCP `render_design_template` accepts `template_id`, `revision`, `data_json` and `output` `pdf` and returns the PDF as an embedded binary resource with small metadata. Neither REST nor MCP stores a new asset or calls an AI provider. The internal AI Agent `design_template.render_pdf` uses the same revision and typed data contract, stores the resulting PDF as an Idea-linked workspace asset and returns only its opaque reference, file metadata and digest to the planner. The conversation shows the generated file with a browser-resolved download; PDF bytes and private storage paths never enter model context. AI Assistant `check_design_template_pdf_render` remains a workflow-authoring check and returns only page count, byte count, digest or field errors.
## Render selected published pages and inspect the manifest
[Section titled “Render selected published pages and inspect the manifest”](#render-selected-published-pages-and-inspect-the-manifest)
Use the same typed JSON object with `render-pages` when an integration needs page images directly, without first creating a workflow execution:
```http
POST /api/v1/design-templates/{tpl_id}/versions/{revision}/render-pages?page_selection=1,3-4&max_page_dimension=1200
Content-Type: application/json
{"customer":"Example Customer","headline":"Example Proposal"}
```
`page_selection` accepts `all`, individual one-based page numbers and ranges. The response identifies the immutable `tplv_` revision, total rendered pages, selected pages and one JPEG item per page with byte count, SHA-256 and a `data_uri`. The longest image side can be 400–1600 pixels. A direct request is limited to 20 selected pages and 20 MB of JPEG data; use a workflow when images must be durable, reused, or larger. Invalid ranges return an RFC 7807 error that states the valid rendered page interval.
MCP `render_design_template` with `output` `pages` accepts the same `template_id`, revision, `data_json`, selection and dimension. It returns the compact manifest as structured content and attaches each JPEG as an embedded binary resource, so base64 data is not copied into the explanatory text. REST and MCP call the same Application service and PDF rasterizer as `design/template_render`. They store no asset, create no execution and call no AI provider.
## A new revision of a published template
[Section titled “A new revision of a published template”](#a-new-revision-of-a-published-template)
A published template always keeps an editable **draft**: you do not need to return it to draft to prepare the next revision. Edit the draft (element commands, or read and `PUT` the whole document), check it with a sample set and the publish readiness, then publish: the result is a new immutable revision (`revision` 2, 3, …). While you work:
* workflows whose `design/template_render` node is pinned to an earlier revision keep rendering it; review and move their pin with the [template revisions](#new-revisions-in-a-saved-workflow) endpoints;
* `document/pdf` and `aggregate/pdf` nodes **without** `template_revision` render the draft at their next run, including unfinished edits; nodes with `template_revision` keep their revision.
`revert-to-draft` is for a different purpose: it takes the template out of selection (status `draft`) while it is reworked. To start from a published revision instead of the current draft, read it with `GET …/versions/{revision}/content` and `PUT` it as the draft, or `POST …/duplicate` it into a new template.
## Publish the reviewed draft
[Section titled “Publish the reviewed draft”](#publish-the-reviewed-draft)
Read `GET /{tpl_id}/draft/publish-readiness` immediately before publishing. Confirm `can_publish: true`, review its checks and use its `draft_etag`:
```http
POST /api/v1/design-templates/{tpl_id}/draft/publish
If-Match: "dd-draft-r4-..."
Idempotency-Key: proposal-publish-001
```
The response gives an immutable `revision`, portable `tplv_` version ID, page count and hashes. The same template/key replays that exact version, even after the draft ETag changes; another payload with that key conflicts. An outdated ETag returns 412 and an unready draft returns 422. Migration 479 commits the publication receipt in the same SQL transaction as the new version and current pointer. MCP `publish_design_template_draft` uses the same service with write scope, `WsAssetsManage` and audit. When receipt retention is enabled, replay history lasts its configured period (30 days by default); retention is disabled by default.
## Archive or return a published template to draft
[Section titled “Archive or return a published template to draft”](#archive-or-return-a-published-template-to-draft)
Read `GET /api/v1/design-templates/{template_id}/draft` first and use its exact `draft_etag` as the `If-Match` header. Send a new `Idempotency-Key` for each operation. `POST /api/v1/design-templates/{template_id}/archive` hides a draft or published template from future selection. An archived template cannot be edited or returned to draft through this command. `POST /api/v1/design-templates/{template_id}/revert-to-draft` makes a published template editable again; publish a new immutable revision when the edit is ready. Both return portable `template_id`, `status`, draft revision/ETag and whether the retry was replayed. A stale ETag returns 412; reuse of a key for a different operation returns 409. Status change and retry receipt commit in one SQL transaction. Workflows pinned to an earlier published revision continue to use that immutable snapshot.
MCP offers `change_design_template_lifecycle` (`action` `archive` or `revert_to_draft`) with the same `if_match` and `idempotency_key`; it is marked destructive for client review. These commands change template availability, so choose the intended template from its draft readback before calling them.
The internal AI Agent offers `design_template.change_lifecycle` with `template_id`, `action` (`archive` or `revert_to_draft`) and exact `if_match`. It requests a dedicated approval tied to that template, action and draft revision. The Agent rechecks the live state after approval and applies the same Application command; an outdated approval changes nothing. This capability has passed offline build and tests and awaits a live Agent planner gate.
## Document-local appearance styles
[Section titled “Document-local appearance styles”](#document-local-appearance-styles)
Native DesignDocument drafts contain an optional `styles` catalog. A style has an `id`, `name`, `kind` (`paint`, `text`, `object`) and bounded appearance `properties`. Text and object styles may link document paint styles through `paintRefs` (`fill`, `stroke` where supported). Elements carry `styleRefs` (`text`, `object`, `fill`, `stroke`) and `styleOverrides`. The editor stores effective appearance on each element so PDF rendering, thumbnails, published revisions, and older readers use the same pixels. Style definitions never include text content, coordinates, dimensions, path data, image source, crop or page layout. Master elements can reference the same document catalog. New blank documents start with editable `Title`, `Subtitle`, and `Body` text styles. Documents created with explicit initial content, including import and clone flows, preserve that content’s style catalog unchanged.
`POST /api/v1/design-templates/{id}/draft/styles/edit` accepts `operation`: `create`, `update`, `update_from_element`, `apply`, `detach`, `reset_overrides`, or `remove`. Optional request fields are `styleId`, `elementId`, `slot`, `name`, `kind`, `properties`, `paintRefs`, and `replacementStyleId`. It requires the exact draft `If-Match` and an `Idempotency-Key`, and returns the usual draft edit response with the changed style or element ID in `element_id`. The outline (`get_design_template` view `outline`) now lists style definitions and each element’s `style_refs`. MCP exposes `edit_design_template_style`; the Agent capability is `design_template.edit_style` with the same operations.
Editing a style updates all linked elements in this document except properties with local overrides. Applying a style clears overrides for its managed properties. Direct element edits create local overrides; `reset_overrides` restores linked values. Removing or detaching a style keeps the element’s current appearance, unless `replacementStyleId` is supplied. The editor’s Styles panel has separate Text, Paint and Object tabs with previews and usage counts. Each row offers apply, edit and delete actions; creation and editing use a dialog with a live preview. Import and export controls are in the panel header. It exports a `madoo.document-styles/v1` JSON file; importing into another document assigns fresh IDs and disambiguates names. Importing does not create a live cross-document link. Workspace-wide style libraries are planned for a later campaign.
## Document-local reusable components
[Section titled “Document-local reusable components”](#document-local-reusable-components)
Native DesignDocument drafts can contain an optional `components` catalog. Each definition has an `id`, `name`, intrinsic `width` and `height`, and a local element tree. Pages and masters reference a definition through a lightweight `component_instance` element with `componentId`. The instance owns its page transform (`left`, `top`, scale, rotation, opacity, visibility and lock state), while the definition owns the internal geometry and hierarchy. Definitions cannot contain other component instances or repeat regions in this version, so expansion is finite and deterministic.
An instance can carry `componentOverrides`, keyed first by definition element ID and then by property name. The bounded override surface supports text and image content, image frame data, fill and stroke paints, opacity, shadows, visibility and style references. Coordinates, dimensions, path geometry, child hierarchy and component references remain definition-controlled. Editing a definition updates every linked instance; resetting an instance clears its overrides. Detaching expands the current appearance into an ordinary group. Deleting a definition detaches its instances so the document keeps the same visible content.
The editor exposes the catalog in the Components document panel. The `+` action opens a dedicated visual component canvas, where a component can be created independently with the compatible drawing, text, image, layer and style tools from the document editor. A separate shortcut creates a component from the current selection, replaces that selection with its first linked instance in the same position, and opens the same visual editor. Existing definitions are edited on that canvas with explicit Save component and Cancel actions; the document canvas and its history are restored when the component editor closes. Catalog thumbnails use the same rendering pipeline as document pages so text, vector paths, lines, images, masks and styles are represented faithfully on a white preview background. Clicking a catalog entry starts placement mode, while dragging it onto the page places the instance at the drop point. A dedicated dialog remains available for the selected instance’s allowed content and appearance overrides. Component definitions and instances participate in undo/redo, save, reopen, revisions, sharing, clone/import, asset and font manifests, thumbnails and PDF rendering. Before placeholder binding and layout, the runtime expands instances into normal element groups; this also makes fields, conditions, image masks and style references inside a component behave like their ordinary element equivalents.
`POST /api/v1/design-templates/{id}/draft/components/edit` accepts `operation`: `create_from_elements`, `insert`, `rename`, `update_definition`, `override`, `reset_overrides`, `detach`, or `remove`. Optional request fields are `componentId`, `elementIds`, `instanceId`, `pageId`, `name`, `left`, `top`, `targetElementId`, and `properties`. It requires the exact draft `If-Match` and an `Idempotency-Key`, and returns the usual draft edit response. The draft outline lists component definitions, definition element IDs, instance counts, and `component_id` on instance elements. MCP exposes `edit_design_template_component`; the Agent capability is `design_template.edit_component` with the same operations.
The catalog is local to one document. Workspace libraries, variants, nested components, component-specific auto layout, cross-document synchronization and separate component import/export are deferred to a later campaign.
## Flow layouts: texts that take the lines they need
[Section titled “Flow layouts: texts that take the lines they need”](#flow-layouts-texts-that-take-the-lines-they-need)
A title that is sometimes one line and sometimes three should not leave a gap below it, nor overlap what follows. A **Layout** region whose elements *fit their content* (`layout.childSizing: "content"`) behaves like a web flex column or an auto-sized InDesign frame: each text with a line rule takes exactly the lines its printed value needs, and everything after it in the Layout moves up or down. Nested Layouts are measured first; an element hidden by its rule or not visible leaves no space. With `sizing: "hug"` the region itself is as large as its content, and `anchor` decides which edge stays in place: `start` (top, the default), `center`, or `end` — a block anchored at the end grows upwards from its bottom edge, like a caption sitting on the bottom of a photo. Layouts written before this setting existed keep drawn sizes (`childSizing` absent).
A text’s line rule is its `grow`: `max_lines` (1–50), `beyond` (`ellipsis`, `shrink`, or `fail`, which stops the render and names the value), and `height`: `content` makes the text exactly as tall as its lines (at least one); `at_least_drawn` (the Repeat-row rule) never makes it shorter than drawn. The canvas, the exact preview and the PDF use the same measurement; the canvas measures the text as written (a field shows its `{code}`), the preview and PDF the sample or runtime values.
On a **free text** — on the page, in a Group, or in a Layout that keeps drawn sizes — `grow` has nothing to push: the box keeps its place and height, and the value may take up to `max_lines` lines and no more than the box holds (at least one). A value that needs more follows `beyond` inside the box — shrunk, cut with an ellipsis, or `fail` with `DESIGN_TEXT_TOO_LONG` (the message gives the lines needed and the lines available); a value that fits prints as drawn, and `height` has no effect. The exact preview and the PDF apply it; the canvas shows the text as written. Everywhere, `shrink` keeps the line limit: a smaller size never wraps the value onto more lines than `max_lines` (or than a free text’s box holds); a value too long even at the minimum size keeps the limit, loses the rest and is reported in the preview’s `DESIGN_TEXT_OVERFLOW`.
A **Repeat** inside a flowing Layout takes the height of the rows it prints — its padding plus the rows (measured when they grow) and the gaps between them; an empty list takes only its padding — so a list of line items followed by a total, or key ideas followed by a quote, keeps what follows right after the last row. The drawn height is the most the list may take: rows beyond it follow the Repeat’s overflow rule (`fail`, `clip`, `fit`) and the list keeps its drawn height. A Repeat that continues on new pages must sit directly on the page, not in a Layout.
A rectangle or image directly inside a Layout can be a **background layer** (`layoutBackground: true`): it is out of the flow, covers the whole measured region (padding included), is drawn behind the other elements and grows with the region. Its colours, gradient and opacity are those of the element; `layout.cornerRadius` rounds all the background layers (images are clipped to it). A photo plus a semi-transparent rectangle makes the classic card with an overlay.
`POST /api/v1/design-templates/{id}/draft/layouts/arrange` accepts `operation`:
* `create` — `element_ids` (same parent) are wrapped in a new Layout in reading order (same visual row left to right, otherwise top to bottom). Defaults: vertical, fits its content, padding 12, gaps 8, elements fit their content (texts without a rule get up to 10 lines then an ellipsis). The padding is placed around the content, so it stays where it was drawn. Optional `name` and `settings`.
* `update` — `layout_id` plus `settings` and/or `order` (every element id of the Layout once, background layers excluded; they keep their place).
* `unwrap` — `layout_id`: the elements return to the parent where the Layout shows them (drawn sizes); background layers become ordinary shapes covering the region’s box.
`settings` fields are all optional: `mode` (`vertical`, `horizontal`, `grid`, `absolute`), `sizing` (`hug`, `fixed`), `child_sizing` (`content`, `drawn`), `anchor`, `padding` (all sides) or `padding_top/right/bottom/left`, `row_gap`, `column_gap`, `columns`, `main_axis_alignment`, `cross_axis_alignment`, `clip`, `corner_radius` (0 removes it). Switching a Layout to `child_sizing: "content"` gives its texts a content-height rule.
```json
{ "operation": "create", "name": "Cover caption",
"element_ids": ["", "", ""],
"settings": { "padding": 24, "row_gap": 8, "anchor": "end", "corner_radius": 12 } }
```
`PATCH /draft/elements/{element_id}` accepts `grow` (`{ "max_lines": 2, "beyond": "ellipsis", "height": "content" }`), `clear_grow`, and `layout_background` (`true`/`false`). The draft outline reports each element’s `parent_id`, a region’s `layout` and a text’s `grow`, and uses the content type names (`layout_region`, `repeat_region`, `component_instance`, `svg_artwork`). MCP exposes `arrange_design_template_elements` and the same fields on `update_design_template_element`; the Agent capabilities are `design_template.arrange_elements` and `design_template.update_element`. All require the exact draft `If-Match` and an `Idempotency-Key`.
## Number formats: how a number field prints
[Section titled “Number formats: how a number field prints”](#number-formats-how-a-number-field-prints)
A number field receives a number (`186000`, `0.25`) and the template decides how it prints — the same idea as a .NET format string with its culture, expressed as options that the editor, REST, MCP and agents all understand and that the editor can preview exactly (they are the options of `Intl.NumberFormat`):
| Option | Values | Example |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `locale` | language and region (`it-IT`, `en-GB`, `de-CH`…) or a data key in braces (`{language}`) | the same template prints `89 €` in Italian and `€89` in English |
| `style` | `decimal`, `currency`, `percent` | a percent takes a fraction: `0.25` prints `25 %` |
| `currency` | ISO code | `EUR`, `USD`, `GBP` |
| `currency_display` | `symbol`, `code` | `89 €` / `89 EUR` (it-IT); placed as the language places it |
| `minimum_fraction_digits`, `maximum_fraction_digits` | 0–20 | `1.234,5` with 0–2 decimals |
| `use_grouping` | `true` (default), `false` | `1.234.567` / `1234567` |
| `sign_display` | `auto`, `always`, `except_zero` | `+3 %` |
| `negative` | `minus`, `parentheses` | `-5` / `(5)` as in accounting |
| `prefix`, `suffix` | up to 24 characters, printed as written | `da 120 m²` |
With `locale: "{language}"` the language is read from the render data key `language` (for example `"it-IT"` in one data object and `"en-GB"` in the other); without it the number prints in English (US).
A **yes/no** (`boolean`) field takes `true_label` / `false_label` (the words printed, default Yes/No) or `boolean_mode: "visibility"` (the element shows only when the value is true); a **color** field takes `color_target` `fill` or `stroke`. Other options on those types are rejected.
Set it with `PUT /draft/elements/{element_id}/placeholder` (`type: "number"`, `format: {...}`), MCP `configure_design_template_element_placeholder`, the Agent capability `design_template.configure_placeholder`, or the editor (Make placeholder → Number → Number format, with a live example).
**Compatibility.** A format written before these options (only locale, style, currency and decimals) keeps printing exactly as before, so published templates never change by themselves: currencies as `EUR 89`, thousands grouped only when the minimum and maximum decimals are equal. Any of the new options — and every format written by the editor, REST, MCP or agents from now on — follows the current rules above.
## Links: elements that open an address
[Section titled “Links: elements that open an address”](#links-elements-that-open-an-address)
Any element can be a **link**: in the PDF, clicking its box opens an address. On a group or a Layout the whole container is the link — a product card, a contact block. The address is `https:`, `http:`, `mailto:` or `tel:` and may contain fields in braces, filled with the render data:
* `{code}` — a field of the document (it must exist as a field of the template);
* `{item.key}` — inside a Repeat row, a key of that row’s item. A key read only by a link joins the list’s contract as an optional text key.
A field inside the address is URL-encoded (`{item.sku}` = `NC 01` gives `NC%2001`). An address that is only a field (`{product_url}`) takes the value as the whole address, which must itself be `https`, `http`, `mailto` or `tel`: a value can never turn a link into a script. When a field has no value, or the result is not a valid address, the element is printed **without its link** and the render reports `DESIGN_LINKS_LEFT_OUT`. Master pages are static: their links cannot read fields.
```json
{ "link": { "href": "https://shop.example/p/{item.sku}?utm_source=whatsapp",
"description": "Open the product page" } }
```
`description` says what the link opens; screen readers read it and some viewers show it. Links exist only in the PDF: the page images (`output: image`, the page manifest) are not clickable, and a PDF/X-4 print file leaves links out — an annotation over the printed area is not allowed there — with the warning `DESIGN_LINKS_OMITTED_FOR_PRINT`. For a flyer shared on messaging apps, send the PDF alongside the image, or print the address next to the item.
Set it with `PATCH /draft/elements/{element_id}` (`link`, `clear_link`), MCP `update_design_template_element`, the Agent capability `design_template.update_element`, or the editor (Fields → the selected element, or *Visibility and link of its elements* for elements inside a container; the Layers panel marks linked elements). The draft outline reports each element’s `link`.
## Pages whose height follows their content
[Section titled “Pages whose height follows their content”](#pages-whose-height-follows-their-content)
A page read on screen or shared as an image — a visual summary, a social card, a receipt — often has content of variable length. Give the page a height rule and the printed page is only as tall as what it prints: it ends `bottom_margin` points below its lowest printed element (hidden elements and elements left out by their rule do not count; master elements do), and it is never taller than the page’s height, which stays the most the page can take. Pages without the rule keep their height, as before. Only output pages take the rule: a master page keeps its size.
Put everything that must follow the content in flowing Layouts (a footer too: an element drawn at the bottom of the page keeps the page tall). Background colours, gradients and images cover the printed size.
In the document the page carries `"fitHeight": { "bottomMargin": 40 }`. Set it with `PUT /draft/pages/{page_id}` (`fit_height_bottom_margin`, `clear_fit_height`), MCP `edit_design_template_page` (operation `update`), the Agent capability `design_template.update_page`, or the editor (Page settings → *Height follows the content*); the canvas marks where the page ends with the active sample. The draft outline reports `fit_height_bottom_margin`. The exact preview, the page images and the PDF all use the printed size.
## Layer folders: organising the layers
[Section titled “Layer folders: organising the layers”](#layer-folders-organising-the-layers)
A **folder** groups page elements in the Layers panel only — like the layer folders of an image editor — so they can be selected, hidden, locked, moved or deleted together while each element stays directly editable. It is not a Group: it has no position, size or effect, its elements keep their page coordinates, and it prints as if it were not there. A hidden folder prints none of its elements; a locked folder locks them in the editor (each element keeps its own visibility and lock, which apply again when the folder is shown or unlocked).
```json
{ "type": "folder", "name": "Header", "visible": true, "locked": false,
"children": [ { "type": "image", "name": "Photo", "left": 0, "top": 0, "...": "..." },
{ "type": "text", "name": "Title", "left": 40, "top": 120, "...": "..." } ] }
```
Folders sit on pages and master pages and nest in other folders (at most 8 levels); they cannot sit inside a Group, Layout, Repeat or component, and carry no field, rule, link, effect or geometry (all zero, scale 1, opacity 1) — `DESIGN_FOLDER_INVALID` otherwise. A folder is a contiguous run of the stacking order: its elements are drawn together, bottom to top. Folders are not part of the template contract, and a Repeat inside a folder still counts as placed directly on the page (it can continue on new pages).
The draft outline reports folders with type `folder`; an element in a folder has the folder as `parent_id`. Every element tool keeps working on elements inside folders by id. In the editor: **Layers → New folder** (with the selected elements, if any), drag rows into, out of and between folders, the folder’s eye and lock, double-click to rename, and the folder menu to remove it (keeping its elements in place) or delete it with them.
## Print color profiles and PDF/X-4 export
[Section titled “Print color profiles and PDF/X-4 export”](#print-color-profiles-and-pdfx-4-export)
Workspace members manage printer-supplied ICC profiles centrally in **Settings > Print & Color > ICC profiles**. The catalog supports multiple immutable CMYK printer profiles, one workspace default, search, and logical archive. Archiving removes a profile from new export choices but does not invalidate published workflows that already pin its GUID and SHA-256. The current default must be replaced before it can be archived.
The DesignDocument export menu keeps **Standard PDF** as the general-purpose path and exposes **PDF/X-4** as an explicit print-ready path. The PDF/X-4 dialog preselects the workspace default, allows another active profile to be chosen, links back to the central catalog, and offers an inline upload action for first use. Profile upload requires an embedding-rights acknowledgement. Profile choice is per export; this bounded version does not persist a separate document-level color-profile preference. The `design/template_render` workflow node stores the chosen profile and publish pins its content hash for reproducible execution.
## Development qualification
[Section titled “Development qualification”](#development-qualification)
After the backend containing DD7 is restarted, run the read-only gate against the published demo:
```powershell
node docs/demo-workflows/design-template-render/qualify-dd7-read-dev.mjs
node docs/demo-workflows/design-template-render/qualify-dd7-versions-dev.mjs
node docs/demo-workflows/design-template-render/qualify-dd7-portable-pdf-dev.mjs
node docs/demo-workflows/design-template-render/qualify-dd7-portable-pages-dev.mjs
node docs/demo-workflows/design-template-render/qualify-dd7-portable-pages-mcp-dev.mjs
```
After migration 478 is applied in Development and the API/Auth hosts are updated, the Development-only create gate makes at most one native blank draft and verifies same-key replay, different-payload conflict and draft ETag without provider calls:
```powershell
node docs/demo-workflows/design-template-render/qualify-dd7-create-dev.mjs
```
After migration 479 and the API restart, the Development gate adds one text element and configures one placeholder at most, checking both replay/conflict paths and stale ETag rejection with zero provider calls:
```powershell
node docs/demo-workflows/design-template-render/qualify-dd7-add-text-dev.mjs
node docs/demo-workflows/design-template-render/qualify-dd7-sample-preview-dev.mjs
node docs/demo-workflows/design-template-render/qualify-dd7-publish-dev.mjs
node docs/demo-workflows/design-template-render/qualify-dd7-typed-budget-dev.mjs
node docs/demo-workflows/design-template-render/install-typed-budget-dev.mjs
node docs/demo-workflows/design-template-render/qualify-dd7-repeating-list-dev.mjs
node docs/demo-workflows/design-template-render/install-repeating-line-items-dev.mjs
node docs/demo-workflows/design-template-render/qualify-dd7-image-field-dev.mjs
node docs/demo-workflows/design-template-render/install-image-field-dev.mjs
node docs/demo-workflows/design-template-render/qualify-dd7-lifecycle-dev.mjs
```
Run the sample/preview gate before publish. The publish gate writes at most one immutable revision and checks same-key replay, stale ETag rejection and the published `headline` contract. Run the typed-budget gate and workflow installer only after the API loads the typed-placeholder command. The installer builds a one-page example with optional base JSON plus visible headline and number inputs that override those JSON keys, PDF and a separate page image. Its recipe uses only the public `tpl_` selector and published revision; no Editor API lookup is needed. The portable installer and its replay gate have passed live. The repeating-list qualifier and installer also passed live, including two rendered rows and a second run without duplicates. The image-box command and workflow also passed live, including one image loaded in exact preview and a replay without duplicates. DD7 lifecycle archive/revert and the closed-catalog pin guard have passed live Development gates after backend restart. The internal AI Agent lifecycle capability has offline coverage and still needs a planner gate; AI Assistant authoring parity and the full five-surface gate remain open.
# External embeds: workflow runtime and editor
This document is the canonical integration guide for embedding Madoo in external systems. It covers both browser surfaces:
* **Workflow runtime embed**: an end-user widget that runs one published workflow.
* **Workflow editor embed**: an embedded editor that can create, save, edit, and run workflows in a workspace.
Use this document whenever code changes touch embed token creation, embed authentication, iframe routes, or `postMessage` contracts.
***
## 1. Integration model
[Section titled “1. Integration model”](#1-integration-model)
External integrations must never expose a Madoo API key or OAuth client secret in the browser. The host application uses its backend to mint a narrow embed token, then passes only that embed token to the iframe.
```text
External backend External browser Madoo
---------------- ---------------- -----
POST /api/v1/auth/token
client_id + client_secret
|
v
POST /api/v1/embed/.../tokens -> returns embed token
|
v
Render iframe with token -> /embed/... or /embed/editor
|
v
Communicate with iframe -> window.postMessage(...)
```
There are two token types:
| Token type | Endpoint | JWT subject | Scope | Main use |
| ------------------- | ---------------------------------- | -------------- | ----------------------------------------------------------------- | --------------------------- |
| Runtime embed token | `POST /api/v1/embed/tokens` | `embed` | One workflow, optional interface, org, workspace, allowed origins | End-user workflow execution |
| Editor embed token | `POST /api/v1/embed/editor/tokens` | `embed_editor` | Org, workspace, roles, permissions, allowed origins | Embedded workflow authoring |
Both token types are JWTs signed by Madoo. Both can be revoked by JTI through the same revocation endpoint.
> **Declarative Apps:** this contract still covers only workflow runtime and workflow editor embeds. Declarative Apps are not embeddable in V1. Their manifest uses `minimumRendererVersion` internally to reject unsupported layout capabilities safely; it does not change the URLs, tokens, or `postMessage` messages described here. A future OR-14 App embed must define its own explicit version handshake instead of implicitly reusing this workflow contract.
The Declarative App editor’s explained hidden-element placeholders are also an internal authoring adapter. They do not change iframe rendering, embed tokens, `postMessage`, or customer-runtime visibility: surfaces without that adapter continue to omit conditionally hidden elements completely.
The contextual Logic panel and authoring contract 2.21 are likewise internal App-authoring surfaces. Editing an operation’s declarative success outcomes changes the next hosted App manifest after save/publish, but adds no iframe command, token claim, external URL, or `postMessage` vocabulary to this V1 embed contract.
The editor’s copied `/apps/{appId}` URL is not an external share or embed URL. It opens the hosted Declarative App route for an authenticated user whose current workspace grants App use access. Anonymous and cross-workspace customer distribution remain outside the current contract.
***
## 2. Server-side authentication
[Section titled “2. Server-side authentication”](#2-server-side-authentication)
First obtain a normal Public API bearer token with client credentials:
```bash
BASE_URL="https://testing-api.madoo.ai"
TOKEN=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=client_credentials" \
"$BASE_URL/api/v1/auth/token" | jq -r .access_token)
```
The API key behind `CLIENT_ID` / `CLIENT_SECRET` determines the organization and workspace used by the embed token. The workspace is not accepted from the browser or from the embed-token request body.
Required permissions:
| Operation | Required permission |
| ------------------------------------------ | ------------------------------------------------------------------- |
| Create runtime workflow token | `ws:executions:create` (`WsExecutionsCreate`) |
| Create editor token | `ws:workflows:manage` (`WsWorkflowsManage`) |
| Run from embedded editor | `ws:executions:create` must also be present in the embedded context |
| Save/create workflows from embedded editor | `ws:workflows:manage` must be present in the embedded context |
For runtime tokens, Madoo copies only the narrow permissions needed by the iframe, and only when the current API key / request context already has them: `ws:workflows:read` for workflow metadata, `ws:assets:read` for reading runtime assets, `ws:assets:manage` for runtime asset upload/management, `ws:executions:create` for starting workflow runs, and `ws:executions:read` for polling runtime execution status and outputs. This lets the runtime iframe access the required APIs without broadening privileges.
For editor tokens, Madoo copies the parent API-key context into the embed token: owner user id, email verification state, organization role, workspace role, and explicit permissions. This is intentional: the embedded editor calls normal workspace APIs, so normal authorization middleware must be able to resolve the same effective permissions.
***
## 3. Runtime workflow embed
[Section titled “3. Runtime workflow embed”](#3-runtime-workflow-embed)
Use a runtime embed when an external end user should fill workflow inputs and generate outputs, without seeing the Madoo editor.
### 3.1 Create a runtime token
[Section titled “3.1 Create a runtime token”](#31-create-a-runtime-token)
```http
POST /api/v1/embed/tokens
Authorization: Bearer
Content-Type: application/json
```
Request body:
| Field | Type | Required | Notes |
| -------------------- | --------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
| `workflow` | string | yes | Published workflow id. Accepts `wf_...` or a raw GUID. |
| `allowed_origins` | string\[] | yes | External origins allowed to host the iframe, for example `https://app.example.com`. |
| `interface` | string or null | no | Custom interface id. Omit or null for the default workflow interface. |
| `expires_in_minutes` | integer or null | no | `>= 1`. Null means practical never-expiry (100-year JWT expiry). |
| `max_executions` | integer or null | no | `>= 1`. Null means unlimited. |
| `prefilled_inputs` | object or null | no | Reserved in the token contract; runtime UI initialization should currently use `madoo:init` / `madoo:setValues`. |
| `end_user_id` | string or null | no | Max 128 chars. Used for audit/correlation and end-user execution isolation. |
Example:
```bash
WF="wf_53fb2f6576fa4bc9a6d3c91a7e84de47"
curl -s -X POST "$BASE_URL/api/v1/embed/tokens" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"workflow": "'"$WF"'",
"interface": "simple",
"allowed_origins": ["https://app.example.com"],
"expires_in_minutes": 60,
"max_executions": 25,
"end_user_id": "customer-10472"
}'
```
Response:
```json
{
"token": "eyJhbGciOi...",
"jti": "emb_7f2e...",
"expires_at": "2026-06-18T15:30:00+00:00"
}
```
Store `jti` server-side if you may need to revoke the token before expiry.
### 3.2 Render the runtime iframe
[Section titled “3.2 Render the runtime iframe”](#32-render-the-runtime-iframe)
Runtime embed route:
```text
{APP_BASE_URL}/embed/{workflowGuid}/{interfaceId?}?token={embed_token}
```
Supported query parameters:
| Parameter | Values | Notes |
| ------------------- | ------------------------------------------ | ------------------------------------------------------------------------------- |
| `token` | JWT | Required. Runtime embed token returned by `POST /api/v1/embed/tokens`. |
| `interface` | string | Optional fallback interface id when not present in the path. |
| `theme` | `light`, `dark` | Optional initial theme class. |
| `primaryColor` | CSS color | Optional initial primary/accent color, applied before iframe readiness. |
| `locale` | locale code | Optional, for example `it` or `en`. Unsupported locales fall back to English. |
| `layout` | `default`, `compact`, `inline`, `headless` | Optional UI layout. |
| `compact` | `true` | Shortcut for compact layout. |
| `hideHeader` | `true` | Hide the workflow/interface header. |
| `hideExecuteButton` | `true` | Reserved by init payload; not all interface renderers expose a separate button. |
| `hidePoweredBy` | `true` | Hide the powered-by label. |
| `hideProgress` | `true` | Hide default progress block when applicable. |
| `hideOutput` | `true` | Hide progress/output area. |
| `showName` | `false` | Hide the workflow/interface name while keeping the rest of the embed UI. |
| `showBranding` | `false` | Hide Madoo branding references, including the powered-by label. |
Custom CSS should be sent with `postMessage` (`madoo:init` payload `css` or `madoo:setCustomCss`) after the iframe loads. Do not place full CSS in the URL.
Example:
```html
```
The hosted SDK can create and manage the runtime iframe for you: It always appends `embed=true` to the iframe URL so Madoo runs in embed mode and does not trigger the normal refresh-token flow.
```html
```
If your backend already returns the clean runtime embed URL, pass it as `iframeUrl` and pass the token separately:
```js
MadooEmbed.create({
container: "#madoo-runtime",
iframeUrl: "https://app.madoo.ai/embed/53fb2f65-76fa-4bc9-a6d3-c91a7e84de47",
token: "EMBED_TOKEN",
});
```
The runtime page bootstraps by calling:
| Internal call | Purpose |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `GET /api/embed/config` | Validates token, resolves workflow GUID to internal workflow id, returns metadata and token execution cap. |
| `POST /api/embed/check-execution` | Increments/checks the per-token execution counter when `max_executions` is set. |
| Standard workflow/execution APIs | Load definition/interface, create execution, poll status, load outputs. The embed token is used as bearer auth. |
If a `design/template_render` node has the opt-in `latest_compatible` policy, a new runtime embed execution can select a compatible published template revision. Its effective pin is stored in that execution’s immutable definition snapshot. The runtime embed does not save or version the workflow. A permanent pin change belongs to authoring in the embedded editor or another authorized authoring surface; a released App binding remains frozen.
`EmbedAuthMiddleware` validates runtime tokens on `/embed/*` and `/api/embed/*`, sets a scoped principal, stores `EmbedClaims` in `HttpContext.Items`, and adds `Content-Security-Policy: frame-ancestors 'self' ...` for embed page requests.
### 3.3 Runtime `postMessage` commands
[Section titled “3.3 Runtime postMessage commands”](#33-runtime-postmessage-commands)
Parent pages send commands to the iframe with:
```ts
iframe.contentWindow?.postMessage(
{
type: "madoo:init",
payload: { locale: "it", values: { prompt: "Summer campaign" } },
},
"https://app.madoo.ai",
);
```
Supported parent-to-iframe commands:
| Message type | Payload | Effect |
| -------------------- | -------------------- | ------------------------------------------------------------------------------------------------- |
| `madoo:init` | `EmbedInitPayload` | Applies initial theme, locale, CSS, values, visibility flags, and layout. |
| `madoo:setValues` | object | Sets input values by field key. Values are JSON-encoded internally by the iframe. |
| `madoo:execute` | none | Starts the workflow execution with current inputs. |
| `madoo:reset` | none | Clears execution state and input values. |
| `madoo:setTheme` | `EmbedTheme` | Applies CSS variables and light/dark mode. |
| `madoo:setLocale` | `{ "locale": "it" }` | Changes iframe language. |
| `madoo:setCustomCss` | `{ "css": "..." }` | Injects sanitized custom CSS. Blocks `url(...)`, `@import`, `javascript:`, and `expression(...)`. |
Values preserve their JSON scalar type. In particular, an `input/number` field must receive a finite JavaScript number such as `60`, not the string `"60"`; an `input/boolean` field must receive `true` or `false`, not text. This is the same strict contract used by editor runs, REST v1, MCP, AI Agent and AI Assistant. It lets reusable workflows safely expose controls such as highlight duration and optional subtitle generation without surface-specific coercion.
An `input/json_value` field receives one complete JavaScript object, array or scalar and preserves it as one structured value, including when the runtime stores a large payload outside the execution row. It does not create iterations. Use `input/json` only when the configured array members must fan out into separate executions. The distinction is identical in an embedded runtime and in a direct REST v1 call.
`EmbedInitPayload` shape:
```ts
type EmbedInitPayload = {
theme?: EmbedTheme;
locale?: string;
values?: Record;
css?: string;
lockedFields?: string[];
hiddenFields?: string[];
hideHeader?: boolean;
hideExecuteButton?: boolean;
hidePoweredBy?: boolean;
hideProgress?: boolean;
hideOutput?: boolean;
showName?: boolean;
showBranding?: boolean;
compact?: boolean;
layout?: "default" | "compact" | "inline" | "headless";
};
```
`EmbedTheme` shape:
```ts
type EmbedTheme = {
mode?: "light" | "dark" | "auto";
primaryColor?: string;
backgroundColor?: string;
surfaceColor?: string;
textColor?: string;
mutedColor?: string;
borderColor?: string;
successColor?: string;
errorColor?: string;
borderRadius?: string;
fontFamily?: string;
};
```
### 3.4 Runtime `postMessage` events
[Section titled “3.4 Runtime postMessage events”](#34-runtime-postmessage-events)
The iframe posts events to the parent with `{ type, payload }`.
Always validate `event.origin` in the parent page before trusting the payload.
```ts
window.addEventListener("message", (event) => {
if (event.origin !== "https://app.madoo.ai") return;
const { type, payload } = event.data ?? {};
if (type === "madoo:execution:completed") {
console.log(payload.outputs);
}
});
```
Runtime iframe-to-parent events:
| Message type | Payload | When emitted |
| --------------------------- | ----------------------------------- | ----------------------------------------------------------- |
| `madoo:ready` | `{ version, fields }` | Config and workflow definition are loaded. |
| `madoo:resize` | `{ width, height }` | Content size changes. Use it to adjust iframe height. |
| `madoo:values:changed` | `{ fieldKey, value, allValues }` | A field value changes. |
| `madoo:validation` | `{ valid, errors }` | Validation event. Reserved for validation-capable UI paths. |
| `madoo:execution:started` | `{ executionId }` | Execution was created. |
| `madoo:execution:progress` | `{ executionId, progress, status }` | Execution status/progress changed while non-terminal. |
| `madoo:execution:completed` | `{ executionId, outputs }` | Execution completed or partially succeeded. |
| `madoo:execution:failed` | `{ executionId, error, code }` | Execution failed or was cancelled. |
| `madoo:error` | `{ code, message }` | Runtime command or execution startup error. |
> **`madoo:error` codes.** `message` is a human, end-user-facing string, safe to display as-is; where Madoo produces a localized message (e.g. `missing_required_inputs`, `invalid_execution_inputs`) it follows the embed `locale`. Known `code` values: `missing_required_inputs` (a required input was left empty; `message` names the field(s) to fill in), `invalid_execution_inputs` (a provided input is not accepted — an unrecognized key and/or a value that is not JSON-encoded; `message` names the offending input(s)), `upload_pending` (an asset upload is still in progress — wait, then retry), `execution_cap_exceeded` (the embed’s execution limit was reached), and `execution_failed` (any other startup error). Branch on `code` for custom handling; new codes may be added over time, so treat unknown codes as a generic error.
The hosted runtime and embedded editor present a concise error summary in their primary UI. Low-level provider bodies, FFmpeg diagnostics, memory addresses and local worker paths are never rendered as the headline; in the editor they remain available only inside an explicit **Technical details** disclosure for authorized troubleshooting. Integrators should apply the same summary-first pattern to any error payload they render themselves.
> **Video previews and seeking.** Madoo-provided video URLs support browser byte-range requests, so an embedded native `