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”POST {BASE_URL}/api/v1/assetsThis is a multipart/form-data request with a single form field named file.
curl -s -X POST "$BASE_URL/api/v1/assets" \ -H "Authorization: Bearer $TOKEN" \ -F "file=@./product-hero.jpg"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:
{ "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:
"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”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:
POST {BASE_URL}/api/v1/assets/importcurl -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:
{ "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:
httpsonly, 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)”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”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).
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":
{ "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:
{ "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”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:
POST /api/v1/assets/uploads/{upload_id}/parts:signContent-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)”PUT the raw file bytes to upload_url, sending every required_headers entry verbatim. The bytes
go directly to storage, not through Madoo:
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)”POST {BASE_URL}/api/v1/assets/uploads/{upload_id}/finalizecurl -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:
{ "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”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 <image>, 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”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.
// 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 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”POST {BASE_URL}/api/v1/structured-data/inspectUse 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.
{ "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 defaultinvariant, 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-ITreads1.234,50and19,90,en-USreads1,234.50. Anything else stays text, never a silently different number:19,90without a locale is the text"19,90"(not 1990), and3.5underit-ITis the text"3.5". Each column holding such values gets aCSV_AMBIGUOUS_NUMBERwarning 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/falseare 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 aCSV_WINDOWS_1252warning. 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”POST {BASE_URL}/api/v1/bundle-manifests/composeThis 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.
{ "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”POST {BASE_URL}/api/v1/bundle-manifests/validateUse 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:
{ "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”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). |
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_bytesandcreated_atare 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 bypath.
7. Get a download URL
Section titled “7. Get a download URL”GET {BASE_URL}/api/v1/assets/download-url?path={path}Returns a public URL to fetch the file you uploaded.
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.
8. Delete an asset
Section titled “8. Delete an asset”DELETE {BASE_URL}/api/v1/assets?path={path}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
pathwill fail. Clean up only assets you no longer need as inputs.
9. Errors
Section titled “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.
10. Workspace fonts
Section titled “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).
GET {BASE_URL}/api/v1/fonts?query=playfair&category=serif&scope=workspaceAll 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:
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 (advanced) — running one workflow over many input sets at once.