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 (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 thews:workflows:readpermission; writes (create, update, validate, estimate, delete, and the lifecycle operations) requirews: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)”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:
{ "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”| 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). |
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”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 withsupports_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) 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.
A sub-workflow — a workflow/sub node, which runs another workflow as one step — names it with
workflow, the wf_… ID that GET /api/v1/workflows and MCP search_workflows
return. Its input and output ports are the called workflow’s inputs and outputs, by their names:
{ "id": "dub", "type": "workflow/sub", "label": "Localize", "parameters": { "workflow": "wf_0f8fad5bd9cb469fa165708167d9e34a" } }Inside an iterated branch the node runs once per item: one child execution per item, each item receiving its
own child’s outputs at its index, and a failed child failing only its item. The called workflow must then return
one value per output (iterated_subworkflow_multi_row_output otherwise). A step runs at most 500 children per run
(configurable): a longer known list is refused at estimate and submission with ITERATED_SUBWORKFLOW_LIMIT_EXCEEDED,
before anything is charged.
Repeat Until Accepted — a workflow/repeat node — names its body the same way, with workflow. It runs
the body up to max_attempts times (default 3, at most 5) until the body accepts its own result, each attempt
receiving the judgement of the one before. The body’s inputs become the node’s inputs, except the three the step
fills itself (attempt_number, previous_feedback, previous_candidate); the node’s outputs are fixed:
candidate, accepted, score, selected_attempt, disposition and report (madoo.attempts-report/v1):
{ "id": "packshot", "type": "workflow/repeat", "label": "Packshot until on product", "parameters": { "workflow": "wf_7c9e6679742540de944be07fc1f90ae7", "max_attempts": 3, "on_exhausted": "best" } }The body must return candidate (any output), accepted and score (Output JSON: a boolean, and a number from
0 to 1) and feedback (Output Text). The full contract, the policies and the report are in
Repeat Until Accepted.
The called workflow must be in the same workspace (workflow_not_found otherwise), and it is usually
created and published first, so that its ID is known. The internal number the editor stores is never part
of a public definition: workflowId is rejected with internal_workflow_parameter, and reading a
definition back always returns workflow, 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”"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.
Values are checked against the model too. A value the node’s model will not accept is a blocking
error, invalid_model_parameter, whether it is written in params or in the node’s own parameter
that the model governs — for example "aspect_ratio": "3_4" on a model that takes "3:4". The
check uses the model the node will run on: the one you name, otherwise your workspace’s effective
default. The message lists the values the model allows, and path points at the value to fix. The
same check runs on create, update, validate, publish and estimate, so an invalid value never
reaches a run.
A preset’s frame is translated, not refused. An aspect-ratio preset written by catalog code
("aspect_ratio": "16_9" on Text to Image) is not the model’s value: on a model with frames of its own
it becomes the nearest frame the model makes (4_5 → 3:4 on MAI Image), on GPT Image the nearest
image_size (16_9 → landscape_16_9), in the estimate and in the run alike. To choose a frame the
presets do not have, set the model’s own parameter: "model": {"model": "mai-image-2.5", "params": {"aspect_ratio": "3:2"}} — the model’s value always wins over the preset.
1.4 Connections
Section titled “1.4 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”When an array input declares "compact": true, connect individual producers through indexed input
names. This example combines three portable checks with utility/quality_gate:
{ "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/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:
{ "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”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.
{ "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 true-peak value is a ceiling: the normalizer aims 0.2 dB under it (FFmpeg lands within a few hundredths
of a dB of its target, on either side), and the check still measures against the ceiling itself.
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”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.
{ "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”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
dubbing controls, in any of its languages, without a reference clip. language accepts the BCP 47 tag the
workflow carries (pt-BR, zh-Hans) or a three-letter code (por): the voice receives its two-letter
primary language (pt, zh).
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.
Several speakers, several voices
Section titled “Several speakers, several voices”A conversation is voiced with one voice per speaker. Transcribe the whole recording in one pass with Speech to
Text and diarization=true: the speaker labels (speaker_0, speaker_1…) then name the same person from start to
end (a recording cut into segments is diarized segment by segment, and its labels do not match across segments).
utility/speaker_voices reads the speakers of that caption track and gives each a different voice of the Text to
Speech model, emitting madoo.speaker-voices/v1; ai/text_to_speech takes it on speaker_voices, with each line’s
speaker on speaker (from the same enumerate/json row as text). A voice connected on voice wins, and a model
without a choice of voice keeps one voice (the run records why).
The voices are distinct while the lists allow. To also match the kind of voice, give speaker_voices a
voice_types input: enumerate/speakers opens one iteration per speaker with a stretch where they talk alone
(sample_start, sample_end), video/trim cuts it, ai/video_analysis with response_format: json_object
answers {"voice": "feminine" | "masculine"} for each, and aggregate/json (columns speaker and voice_type)
collects them. voices fixes a speaker’s voice and always wins. The default voice lists are ElevenLabs voices;
change feminine_voices, masculine_voices and other_voices for another model.
Named stem separation
Section titled “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”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.
{ "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”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:
{ "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 ducking by phrases
Section titled “Measured ducking by phrases”audio/duck lowers the background under the foreground speech and then mixes both tracks through a
peak-safety limiter. Both inputs must be storage-backed audio.
The default technique: "phrase" works like an editor: it finds the phrases in the foreground, and a pause
shorter than pause_bridge_ms stays inside the phrase, so the music holds still between words instead of
rising and falling at every breath (“pumping”). The music starts moving down attack_ms before each phrase, so
the first word is already clear, stays duck_depth_db lower for the whole phrase and comes back up over
release_ms in the real pauses. Where a pause begins is decided 24 dB under the loudness of the voice, or at
threshold_db when set. technique: "compressor" keeps the RMS sidechain compressor (threshold_db,
ratio, attack_ms, release_ms), which reacts to every word:
{ "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.
| Profile | Phrase: depth, pause bridge, lead, return | Compressor: threshold, ratio, attack, release | Background |
|---|---|---|---|
voice_over (and custom) |
−10 dB, 900 ms, 250 ms, 800 ms | −32 dB, 6:1, 15 ms, 350 ms | −6 dB |
subtle |
−6 dB, 1200 ms, 400 ms, 1200 ms | −24 dB, 3:1, 30 ms, 600 ms | −3 dB |
aggressive |
−16 dB, 600 ms, 150 ms, 500 ms | −38 dB, 10:1, 5 ms, 250 ms | −9 dB |
Explicit duck_depth_db, pause_bridge_ms, threshold_db, ratio, attack_ms, release_ms,
background_gain_db, and foreground_gain_db values override the selected profile. A foreground in which no
phrase is found leaves the music unchanged and the check reports the warning DUCKING_NO_SPEECH_DETECTED.
A node that sets ratio without technique was tuned for the compressor and keeps it: ratio exists only
there. 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”audio/speech_enhance cleans one storage-backed recording locally before delivery processing. Start with a
profile and add only deliberate overrides:
{ "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.
Transcription and caption translation
Section titled “Transcription and caption translation”A clip or a reel is transcribed in one pass. With granularity=segment or word, Speech to Text also emits
caption_track, a madoo.caption-track/v1 holding the cues of its SRT and WebVTT (phrases for word timing), so
the translation starts from it directly:
video/extract_audio → ai/speech_to_text.caption_track → enumerate/caption_blocks → ai/caption_translation → aggregate/caption_trackConnect the same caption_track to aggregate/caption_track.source_track. Long media uses domain-specific
enumerator/aggregator pairs rather than a generic JSON loop:
video/extract_audio → enumerate/audio_segments → ai/speech_to_text → aggregate/transcriptaggregate/transcript.caption_track → enumerate/caption_blocks → ai/caption_translation → aggregate/caption_trackaggregate/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 (the default) 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.
A natural-speech round is enumerate/caption_blocks → ai/caption_translation → aggregate/caption_track
(source_track = the previous pending_captions) → enumerate/json → ai/text_to_speech →
audio/speech_timing_check → aggregate/json (column candidate) → utility/speech_timing_reconcile, with
the blocks, translation and voice in duration-aware mode and the previous state on every timing input; three
rounds, the last with finalize. Validation reports a broken round before the run, with the code
natural_speech_wiring and the node that needs the fix: a duration-aware voice or block without its timing
state or cue ID, a timing state on a node that is not duration-aware, a measurement whose audio and
generation do not come straight from the same duration-aware voice, a reconcile without its state, a
duration-aware manifest reading a round that does not finalize, a dub timeline that would trim speech
(clip_overflow_policy=legacy_trim), a plan without a duration, and a maximum_end_extension_ms over the
limit the run applies (250 ms measured on reference_audio, or up to 2000 ms with
use_available_caption_window). The manifest describes the delivery with the delivery_policy of the final
reconcile, so a best_effort dub yields its manifest and quality report even when some lines are degraded.
Speech to Text reports the detected language as a BCP 47 tag (en, not eng), which is also the language of
its captions and WebVTT headers.
For nested composition, a workflow/sub node (see §1.2
for how it names its workflow) 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. Its value parameter stores a default document (the JSON itself, or its text) that a
run uses when it passes none — a value passed to the run always replaces it. 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”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”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”The optional interfaces[] section defines custom interfaces
in the same shape the read API exposes, plus a mapping on every field and output that says where its
value goes. Invalid mappings are blocking errors (invalid_interface). Note that custom interfaces may
be gated by your plan.
A custom interface is also the way to set a node’s parameter on every run. A parameter such as
the x of image/crop is fixed in the definition; when a field maps to it, the value sent with the
execution replaces it for that run — a crop box, a strength, a duration computed by your backend.
There is no other way to pass a node parameter in POST /executions: the request takes inputs
(and the interface to read them through), not node parameters.
| Where | mapping |
Points to |
|---|---|---|
a field, fields[] |
{ "target_type": "node_input", "node_id": "<id>" } |
an input node (input/text, input/number, input/image, …): the field is that input’s value |
a field, fields[] |
{ "target_type": "node_parameter", "node_id": "<id>", "parameter_key": "<name>" } |
a parameter of any node, by its catalog name (GET /node-types/{type}) |
an output, outputs[] |
{ "node_id": "<id>" } |
an output node (output/image, output/json, …) |
target_type takes exactly these two values; anything else (input, parameter) is refused with
unknown mapping target type. An output mapping has no target_type, and every output needs its
node_id (an empty one is refused with mapping references non-existent node ''). These names are
part of the v1 contract and stay stable.
A workflow that crops one cell of a photo, with the box chosen per run:
{ "schema_version": "1.0", "nodes": [ { "id": "photo", "type": "input/image", "label": "Photo", "parameters": { "required": true } }, { "id": "crop", "type": "image/crop", "label": "Crop", "parameters": { "x": 0, "y": 0, "width": 100, "height": 100 } }, // replaced on every run { "id": "out", "type": "output/image", "label": "Cell", "parameters": { "output_name": "cell" } } ], "connections": [ { "from_node": "photo", "from_output": "image", "to_node": "crop", "to_input": "image" }, { "from_node": "crop", "from_output": "result", "to_node": "out", "to_input": "image" } ], "interfaces": [{ "id": "cell-crop", "name": "Crop one cell", "is_default": true, "fields": [ { "key": "photo", "label": "Photo", "type": "image", "required": true, "mapping": { "target_type": "node_input", "node_id": "photo" } }, { "key": "cell_x", "label": "Cell x", "type": "number", "required": true, "mapping": { "target_type": "node_parameter", "node_id": "crop", "parameter_key": "x" } }, { "key": "cell_y", "label": "Cell y", "type": "number", "required": true, "mapping": { "target_type": "node_parameter", "node_id": "crop", "parameter_key": "y" } }, { "key": "cell_width", "label": "Cell width", "type": "number", "required": true, "mapping": { "target_type": "node_parameter", "node_id": "crop", "parameter_key": "width" } }, { "key": "cell_height", "label": "Cell height", "type": "number", "required": true, "mapping": { "target_type": "node_parameter", "node_id": "crop", "parameter_key": "height" } } ], "outputs": [ { "key": "cell", "label": "Cell", "mapping": { "node_id": "out" } } ] }]}Run it through the interface — name it in interface and key the inputs by the field keys:
{ "workflow": "wf_…", "interface": "cell-crop", "inputs": { "photo": { "asset_path": "…/assets/…/photo.jpg" }, "cell_x": { "value": "300" }, "cell_y": { "value": "200" }, "cell_width": { "value": "400" }, "cell_height": { "value": "250" } }}The result’s cell is the 400 × 250 region at (300, 200). Without "interface": "cell-crop" the run uses
the default interface, where only photo exists and the crop keeps the values saved in the definition.
When a node takes the value on a port instead (many do: a Predicate’s Compare With, Calculate’s
variables), connect an input/number or input/text to that port: no custom interface is needed.
1.7 Limits
Section titled “1.7 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”POST {BASE_URL}/api/v1/workflows{ "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 fullerrors[]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”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.
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:
{ "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”Every issue — blocking or not — carries structured locators and a JSONPath into the document you submitted, plus an actionable suggestion when there is one:
{ "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." }Issue code catalogue
Section titled “Issue code catalogue”Every code below is what arrives in errors[] / warnings[], always in lower case. E blocks (create and
update answer 422, validate returns valid: false, publish refuses); W is a warning (dropped input or a
note); W* is a warning that also makes authoring_ready false.
The checks run in two passes. The first reads the document against the catalog (node types, parameters, models, templates, ports); if it finds an error, the second — the graph rules, interfaces and input assets — does not run, so fix the first-pass errors before you expect the rest. Validate without a body and publish check the stored definition and run only the second pass (publish also checks input assets).
Document and catalog
| Code | When | |
|---|---|---|
unsupported_schema_version |
E | schema_version is not "1.0". Nothing else is checked. |
too_many_nodes, too_many_connections |
E | Over the limits of §1.7. Nothing else is checked. |
invalid_node_id, duplicate_node_id |
E | A node id breaks the id pattern, or is used twice. |
unknown_node_type |
E | The node type does not exist or is not active. |
unknown_parameter |
W | A parameter the node type does not declare (dropped). |
invalid_parameter_value |
E | A preset or effect value has the wrong shape, or a preset parameter has no category. |
unknown_preset, unknown_effect |
E | The preset or effect code does not exist. |
internal_workflow_parameter |
E | An internal workflow-number parameter written on workflow/sub; use workflow. |
dynamic_config_flat_override |
W | A composite configuration replaced a flat parameter sent next to it. |
unknown_layout_node, unknown_layout_interface |
W | The layout block names a node or an interface that does not exist (dropped). |
invalid_interface_layout |
W | An interface’s layout is not valid JSON of the expected shape (dropped). |
Templates and sub-workflows
| Code | When | |
|---|---|---|
missing_template_id |
E | document/pdf or aggregate/pdf without template_id, or a template_revision without template_id. (design/template_render without a template reports missing_dynamic_mapping.) |
invalid_template_id, template_not_found |
E | template_id is not a tpl_ id, or no template of the workspace has it. |
invalid_template_revision, template_revision_not_found |
E | template_revision is not a positive integer, or the template has no such revision. |
template_pin_failed |
E | design/template_render could not fix the selected revision (the message says why). |
internal_template_parameter |
E | Internal document parameters (documentId, templateContract, …) written by hand. |
template_field_not_connected |
E | A required template field without a default is not connected. |
missing_workflow |
E | workflow/sub without workflow. |
invalid_workflow_id, workflow_not_found |
E | workflow is not a wf_ id, or no workflow of the workspace has it. |
subworkflow_interface_unavailable |
E | The child workflow’s definition cannot be read, so its ports are unknown. |
Models
| Code | When | |
|---|---|---|
model_ignored |
W | model on a node with no AI capability (dropped). |
unknown_model |
E | The model slug does not exist or is not enabled. |
model_not_allowed_for_capability |
E | The model does not do the capability the node needs. |
unknown_capability |
E | A capability_models key that is not a capability of the node. |
ambiguous_model_override |
E | model on a node with several capabilities; use capability_models. |
capability_models_normalized |
W | capability_models on a single-capability node, folded into model. |
model_params_ambiguous_for_multi_capability |
E | model.params on a node with several capabilities. |
model_params_schema_unavailable |
W | params sent but the model publishes no parameter schema (the whole bag is dropped). |
unknown_model_parameter |
W | A model parameter the model does not declare (dropped). |
invalid_model_parameter |
E | A value the node’s model will not accept — see §1.3. |
unsupported_model_capability |
E | ai/speech_to_text asks for an option its model does not offer. |
timing_required |
E | ai/speech_to_text asks for SRT or VTT with plain-text granularity. |
Connections and ports
| Code | When | |
|---|---|---|
unknown_node |
E | A connection names a node id that is not in nodes. |
invalid_connection |
E | A connection has an empty from_output or to_input. (Two identical connections are not an error.) |
unknown_port |
E | The port does not exist on that node, counting the ports its configuration creates. |
connection_type_mismatch |
E | The two ports’ data types do not fit — same rules as the editor: equal types, any, a single output into an array slot, pdf↔document. Text or media wired into a utility/calculate variable too. A text wired into a json input: the message points to utility/json_parse, which reads the text as JSON. |
port_cardinality_below_minimum, port_cardinality_exceeded |
E | A list input has fewer connections than its min_count, or more than its max_count. |
cycle_detected |
E | The connections form a loop. |
definition_too_large |
E | The definition has more than 2,000 nodes or 10,000 connections. Split it into child workflows. |
Ports created by configuration (see 09 — dynamic ports)
| Code | When | |
|---|---|---|
invalid_dynamic_config |
E | A configuration that creates ports is malformed: a formula that does not parse, variants out of range, bad segments, a reserved template placeholder, data-rows limits, a bad template contract. The path points at the field. |
missing_dynamic_mapping |
E | The configuration that should create the ports is missing: an aggregator without columns, a split without segments, design/template_render without a template. |
missing_source_field |
E | A mapping without its sourcePath / sourceColumn. |
invalid_port_name, duplicate_dynamic_port, reserved_dynamic_port, reserved_port_name |
E | A port name that is not valid, used twice, or reserved. |
invalid_port_type |
E | A port type that is not supported, or a media_type that is not a media type. |
invalid_no_match_behavior, missing_case_key, duplicate_case_key, invalid_case_count |
E | utility/select_by_key cases are wrong. |
invalid_question_count, missing_question_name, invalid_question_type, missing_question_instructions, invalid_threshold, missing_question_options, invalid_option, duplicate_option, invalid_option_count, missing_question_levels, invalid_level, duplicate_level, invalid_level_count |
E | ai/decide questions are wrong (madoo.decision-questions/v1). The path points at the question and the field. |
missing_decision_content |
E | ai/decide has nothing to judge: connect content, images or image_set, or write the content parameter. |
locate_subject_missing |
E | ai/locate_subject does not know what to find: connect reference pictures to references or a description, or write the description parameter. |
invalid_locate_subject_parameter |
E | An ai/locate_subject parameter out of range or not one of its values — framing (subject, context), margin (0–0.5), aspect (none, 1:1, 4:5, 3:4, 2:3, 9:16, 5:4, 4:3, 3:2, 16:9) or on_not_found (continue, fail); the message names the parameter. |
invalid_on_uncertain, invalid_image_detail |
E | ai/decide on_uncertain is not mark, skip_branch or fail; image_detail is not low or high. (invalid_image_set_limit as for Image Analysis.) |
invalid_default_track, invalid_language_tag, invalid_track_label, duplicate_track_label, invalid_track_kind, invalid_track_format, invalid_track_count |
E | Caption tracks are wrong. |
dynamic_config_not_canonical |
E | A stored configuration is not in the form the step reads (only on a stored definition written outside the API). |
template_placeholder_not_declared, template_placeholder_invalid_marker, template_placeholder_marker_missing |
W* | text/template markers and declared placeholders disagree (not checked when the template input is connected). |
text_template_placeholder_unconnected |
W* | A declared text/template placeholder input is not wired. |
Graph shape
| Code | When | |
|---|---|---|
disconnected_node |
W* | A step with no connections at all. |
no_output_nodes |
W* | No enabled output/* node. |
unused_input_node |
W* | An input feeds nothing. |
dead_end_branch |
W* | A branch never reaches an output. |
unsupported_inline_node_reference |
W* | {{node.port}} or ${node.port} written inside a parameter — connect the port instead. |
Specific steps
| Code | When | |
|---|---|---|
output_json_ambiguous_input |
E | output/json has both text and json connected; wire exactly one. |
text_template_missing_template |
E | text/template with no template text and no template input. |
code_missing_content |
E | image/code with no content and no content input. |
calc_unconnected_variable |
E | A variable of a utility/calculate expression is not connected. |
text_expression_unconnected_variable |
E | A variable a text/expression expression reads is not connected. |
text_placeholder_unconnected |
E | A {{name}} in the texts of the text_track of video/text_overlay or the text_layers of image/text_overlay (not checked when the track or layers arrive on the node’s JSON input), or in the template of utility/json_compose, is not connected. |
text_track_requires_typography |
E | A video/text_overlay has a text_track (in the parameter or on its input) but no typography: a track is drawn only with a typography style (madoo.video-typography/v1; get_node_type shows the default). Without one the node would ignore the track. |
invalid_json_input_value, json_input_value_too_large |
E | The value of an input/json_value is not a JSON document, or is larger than 256 KB. The message gives the parser’s reason. |
missing_images |
E | ai/image_analysis with no image connected: wire at least one image (images) or an image set (image_set, madoo.image-set/v1). |
invalid_image_set_limit |
E | ai/image_analysis with an Image Set Limit other than even, first or fail. |
invalid_extract_scenes_parameter |
E | A video/extract_scenes parameter out of range or not one of its values: mode, frame_format, threshold (0.05–1), black_gap_seconds (0–10), min_scene_seconds (0–60), interval_seconds (0.5–3600), max_frames (1–500), max_frame_width (0–3840); the message names the parameter. |
invalid_image_statistics_parameter |
E | An image/statistics parameter out of range or not one of its values — grid (1–16), alpha_min, mask_threshold, max_samples, bands (increasing from 0 to 1, at most 8 bands), near_black, near_white, untagged, or a minimum above its maximum (min_white_share above max_clipped_white, min_median_luma above max_median_luma); the message names the parameter. |
invalid_image_detail_parameter |
E | An image/detail parameter out of range or not one of its values — patch_size (8–128), stride, context_size (at least patch_size), max_patches, max_regions (1–3), min_separation, luma_min, luma_max, flat_gradient_max, alpha_min, mask_threshold, untagged, or a select that is not a list of distinct roles among low_gradient, detail and bright_transition; the message names the parameter. |
invalid_instance_segment_parameter |
E | An image/instance_segment parameter out of range or not one of its values — score_threshold (0.05–1), max_instances (1–200), mask_threshold (0.05–0.95), min_area_px, select (all, largest, highest_score), untagged, or a class_filter naming a class the model does not know (it recognizes the 80 COCO categories, by name or COCO id); the message names the parameter. |
invalid_face_detect_parameter |
E | An image/face_detect parameter out of range or not one of its values — score_threshold (0.1–1), max_faces (1–100), select (largest, highest_score), framing (face, head, head_and_shoulders), aspect (none, 1:1, 4:5, 3:4, 2:3, 9:16, 5:4, 4:3, 3:2, 16:9) or untagged; the message names the parameter. |
invalid_crop_parameter |
E | image/crop with an On Out Of Bounds other than fail or clip. |
crop_dimensions_missing |
W | image/crop without a region connected and without a width or height of at least 1: the step fails when it runs. Set both, or connect a rectangle to region. |
duplicate_check_id |
E | Two producers feed one quality gate with the same check_id. |
natural_speech_wiring |
E | A natural-speech dubbing round is wired wrongly (see the dubbing section above). |
json_schema_source_invalid, json_schema_source_missing, json_schema_source_ambiguous |
E | utility/json_schema_validate, utility/json_parse or utility/json_compose has a wrong, missing, or double schema source (utility/json_parse and utility/json_compose also accept none, their default; utility/json_compose has no input source). |
json_schema_remote_refs_unsupported |
E | allow_remote_refs is true. |
json_schema_catalog_ref_unknown |
E | schema_ref is not in the schema catalogue. |
json_schema_too_large, json_schema_invalid, json_schema_unsupported_dialect, json_schema_invalid_reference, json_schema_remote_ref_forbidden |
E | The inline schema_definition is too large, not a valid schema, of an unsupported draft, or with a reference that cannot be resolved locally. |
json_schema_too_complex |
E | The inline schema_definition expands to more than 100,000 subschemas once its $ref are followed (for example the same $ref repeated inside nested allOf). Reference shared parts once. |
parameter_schema_unavailable, parameter_schema_invalid, parameter_schema_validation_failed |
E | A JSON parameter tied to a schema: the schema is missing, the value does not satisfy it, or it could not be checked. |
Iteration and pricing
| Code | When | |
|---|---|---|
enumerator_on_iterative_source |
E | An enumerator fed by another enumerator’s per-item stream (nested fan-out). |
iterated_subworkflow_multi_row_output |
E | A workflow/sub step inside an iteration (it runs once per item) calls a workflow that returns several values for an output; that workflow must collect its own list with an aggregator. |
repeat_settings_invalid |
E | A workflow/repeat step with max_attempts outside 1–5, an unknown on_exhausted, or minimum_score_to_deliver outside 0–1 or set with a policy other than best. |
repeat_reserved_input |
E | A connection into attempt_number, previous_feedback or previous_candidate of a workflow/repeat step: the step fills them for every attempt. |
repeat_body_contract_invalid |
E | The body of a workflow/repeat step lacks candidate, accepted (Output JSON), score (Output JSON) or feedback (Output Text), returns several values for one of them, or declares a reserved input of the wrong type or as required. |
repeat_feedback_unused |
W | The body of a workflow/repeat step with more than one attempt does not read previous_feedback: every attempt starts from the same inputs. |
enumerator_ambiguous_input, enumerator_missing_input |
E | An enumerator with both, or neither, of its alternative data inputs connected. |
runtime_pricing_iteration_unsupported |
E | A step whose model is priced on the measured input (media length) inside an iteration. |
iteration_without_aggregator |
W | An enumerator whose per-item results are never collected by an aggregator. |
possible_cartesian_iteration |
W | A step fed by two independent enumerators: it runs once per combination. |
Interfaces
| Code | When | |
|---|---|---|
invalid_interface |
E | An interface breaks its rules (id, name, empty, duplicate key, mapping, an output used twice). |
duplicate_interface_id, multiple_default_interfaces |
E | Two interfaces share an id, or more than one is the default. |
interface_id_default_overload |
W | A custom interface uses the id default. |
interface_input_mapped_as_parameter |
E | A field maps an input node as a node parameter. |
interface_field_constraints_incompatible |
E | Field constraints do not fit its type (step ≤ 0, min > max, …). |
interface_parameter_not_found, interface_parameter_type_mismatch, interface_parameter_category_mismatch, interface_parameter_category_unavailable, interface_parameter_options_empty |
E | A field mapped to a node parameter that does not exist, has another type or category, or offers no options. |
interface_parameter_catalog_unavailable |
E | The catalog could not be read to check a mapped parameter; retry. |
Input assets (default files of input nodes)
| Code | When | |
|---|---|---|
unresolvable_input_default |
E | The default is empty or not a usable asset reference. |
asset_not_authorized |
E | The file belongs to another workspace, or is not a Madoo storage reference. |
asset_upload_pending, asset_not_found |
E | The upload is not finalized, or the file no longer exists. |
invalid_document_format |
E | A document input’s file is not a document. |
asset_validation_unavailable |
E | Storage could not be checked; retry. |
Reading a definition back (§4) adds unresolved_reference as a warning on export only.
3. Validating without saving
Section titled “3. Validating without saving”POST {BASE_URL}/api/v1/workflows/{id}/validateTwo modes:
- With a body
{ "definition": { … } }— validates that document (full catalog resolution + graph checks) without touching the stored one. The pre-flight you run before aPUT. - 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.
A stored draft is not guaranteed valid. REST and MCP refuse a definition with blocking errors (422), but the
visual editor saves work in progress as it is — a draft opened in the editor can read back with valid: false.
Only publishing is validity-gated. Automation that picks up a draft it did not write must validate it (no body
= the stored definition) before relying on it.
{ "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”GET {BASE_URL}/api/v1/workflows/{id}/definitionGET {BASE_URL}/api/v1/workflows/{id}/definition?version=3Returns the definition in the public schema, wrapped with the version it belongs to:
{ "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:
GET {BASE_URL}/api/v1/workflows/{id}/versions{ "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”PUT {BASE_URL}/api/v1/workflows/{id}/definitionBody: { "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
versionand 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”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:
GET /definitionreturns anETagheader — a fingerprint of the definition content.- Send it back on the
PUTin anIf-Matchheader. - 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 get412(precondition_failed): re-read, re-apply your change, retry.
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”Metadata — name, description, tags — changes without touching the definition:
PATCH {BASE_URL}/api/v1/workflows/{id}{ "name": "New name", "tags": ["v2"] } // absent fields are left unchangedDeletion is a soft delete:
DELETE {BASE_URL}/api/v1/workflows/{id} → 204If 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:
GET {BASE_URL}/api/v1/workflows?status=draft // draft | published | archived | allGET /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”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.
POST {BASE_URL}/api/v1/workflows/{id}/publishPOST {BASE_URL}/api/v1/workflows/{id}/archivePOST {BASE_URL}/api/v1/workflows/{id}/revertPOST {BASE_URL}/api/v1/workflows/{id}/clonePublish 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
422validation_errorenvelope 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. RunPOST /{id}/validatefirst 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:
// 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)”POST {BASE_URL}/api/v1/workflows/{id}/estimateAnswers “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.
A value a node’s model will not accept is answered with the same 422 envelope and an
invalid_model_parameter issue, with its path. Any other refusal of what you asked — a missing
required input, an unknown interface, a dataset that cannot be read, a limit — is a 422 whose
code names it (missing_required_inputs, invalid_interface_id, …). An estimate never answers
500 for something you can fix. Over MCP, estimate_execution returns the same issues in errors[],
or a validation_error with the same explanation.
For example, pass an uploaded dataset when its row count controls paid fan-out:
{ "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.
{ "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.
{ "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 onprojection_status/deferral_reasonbefore reading any amount, and treatestimated_creditsas nullable: it became so whenruntime_unit_pricewas 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. 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”# 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 retriesWF=$(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-Matchcurl -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 is the companion reference for every key you write in a definition; 04-executions.md for running what you built.