Integration Guide
This is a start-to-finish guide for calling Macaroni’s video-generation service from another product. drpost is the worked example throughout — substitute your own product name; nothing here is drpost-specific. It covers authentication, the two integration surfaces (REST and MCP), the two flows (interactive session vs batch job), the exact poll loop to implement, real request/response shapes, and the full error and failure model with recovery. You should be able to implement against it without reading source.
- Base URL:
https://api.macaroni.video - Reference: API.md (REST) · MCP.md (agents). REST and MCP are 1:1 across the
integrator surface — the same engine, billing, shapes, and error contract back both; pick
whichever fits your runtime. (The
/adminprovisioning surface is deliberately REST-only.) - Everything is asynchronous. You submit, then poll for the result. There is no synchronous “give me a video” call.
- Everything costs integer credits. Costs are always quoted in credits, never dollars.
Recommendation for drpost: use the interactive session flow over REST (below). It is the more thoroughly proven path, gives you gate-based review, and returns a hosted, shareable video link. Use the
animated-explainerpipeline for a guaranteed hosted deliverable — it is the only pipeline certified for hosted delivery today (see Pipeline readiness).
Quick start — plug Macaroni into your agent (3 steps)
Section titled “Quick start — plug Macaroni into your agent (3 steps)”1. Get a key. Sign up at https://app.macaroni.video (email magic link). Your default
API key (mac_…) is created at signup — copy it from the one-time reveal, or mint another anytime
in the dashboard’s Keys console.
2. Connect your agent — pick one:
-
(a) MCP — add the Macaroni MCP server. Claude Code (CLI):
Terminal window claude mcp add --transport http macaroni \https://api.macaroni.video/mcp \--header "Authorization: Bearer $MAC_KEY"Other MCP clients (Claude Desktop, etc.) — raw config:
{"mcpServers": {"macaroni": {"type": "http","url": "https://api.macaroni.video/mcp","headers": { "Authorization": "Bearer YOUR_API_KEY" }}}} -
(b) Skill — download, unzip, done:
- Download: macaroni-mcp.zip (MCP agents, e.g. Claude Code) · macaroni-api.zip (REST integrators). Browse first: SKILL.md.
- Unzip it into your project’s
.claude/skills/folder — Claude Code picks it up automatically. Using another agent or the skills CLI? From where you unzipped:npx skills add ./macaroni-mcp -y
Advanced — terminal one-liners
macOS / Linux / Git Bash / WSL:
Terminal window mkdir -p .claude/skills && curl -sL https://macaroni.video/skills/macaroni-mcp.tgz | tar -xz -C .claude/skillsWindows PowerShell (
curl.exe/tarship with Windows 10+):Terminal window mkdir -Force .claude/skills | Out-Null; curl.exe -sL https://macaroni.video/skills/macaroni-mcp.tgz -o "$env:TEMP\macaroni-mcp.tgz"; tar -xzf "$env:TEMP\macaroni-mcp.tgz" -C .claude/skillsskills CLI from the tarball:
curl -sL https://macaroni.video/skills/macaroni-mcp.tgz | tar -xz -C /tmp && npx skills add /tmp/macaroni-mcp -y(swapmacaroni-mcpformacaroni-apiin any of these)
3. Ask for a video. Tell your agent: “Make a 15-second vertical explainer about X.” It opens a session, converses, hits approval gates (you approve or revise), and returns a hosted, shareable video link. Everything is priced in integer credits — opening a session holds a budget, each turn captures its actual spend, and closing releases the rest (see §3).
One pipeline —
animated-explainer— is certified for hosted delivery today; the others are experimental (they may finish without a hosted video). Prefer interactive sessions over batch (batch is disabled). That is the whole product; the rest of this guide is the detail behind these three steps.
1. Authentication
Section titled “1. Authentication”Every request except GET /health and the public share route carries:
Authorization: Bearer <your-api-key>Keys start mac_…. They are account-scoped: everything you create is visible only to your account.
Keys are minted self-serve at signup (https://app.macaroni.video) — your default mac_…
key appears once at signup, and you can mint more in the dashboard’s Keys console. Operator
(/admin) provisioning of accounts, keys, and credit grants still exists as a secondary path
for concierge/special cases (see API.md → Admin / provisioning).
- Missing bearer →
401 { "error": "missing_api_key" } - Unknown/revoked key →
401 { "error": "invalid_api_key" }(over MCP: JSON-RPC error-32001)
Self-serve signup (the primary path). Get your first key in the browser: sign in at
https://app.macaroni.video (Supabase Auth email magic link); the account’s default mac_ key
is minted at signup and shown once (mint more later in Keys). Under the hood the site
presents the Supabase access token (a JWT, not a mac_ key) to POST /v1/account, which
returns the account + mac_ key. This is a browser-facing surface with no MCP twin (agents
don’t sign up) and is live in production today. See API.md → Get an API key (self-serve
signup) for the full /v1/account contract. Everything below is identical once you hold a mac_
key, however you obtained it.
Health check (no auth), useful for readiness probes:
curl -s https://api.macaroni.video/health# → {"ok":true,"service":"macaroni-gateway","ts":"2026-07-09T02:27:35.831Z"}Check your balance before spending:
curl -s $BASE/v1/credits -H "authorization: Bearer $KEY"# → { "available_credits": 3263, "ledger": [ … ] }2. Which flow to use
Section titled “2. Which flow to use”Interactive session (/v1/sessions) | Batch job (/v1/jobs) | |
|---|---|---|
| Availability | LIVE — the only currently-working path to a delivered video | CURRENTLY UNAVAILABLE — returns 503 batch_unavailable, no credits held (no worker; MACARONI_BATCH_ENABLED off by default) |
| Model | Multi-turn conversation with approval gates | Fire-and-forget, no gates |
| Review / steer | Yes — approve/revise the script & scenes | No |
| Approvals | You resolve gates yourself | autopilot=false is a dead-end (no resume endpoint) — use only autopilot=true |
| Recommended for drpost | Yes — use this | Not usable today (see §8) |
| Credits | Holds a budget, captures per turn, refunds the rest on close | Conservative hold, reconciled on completion (when enabled) |
Build against the interactive session flow only. Batch (
/v1/jobs/ MCPsubmit_job) is disabled and returns503 batch_unavailablewith no credits held — a drpost engineer must not build against it today (see §8).
Both flows deliver a video only through the gateway’s hosted render tier, and only
animated-explainer is certified for that today. Other pipelines are experimental and may finish
their work without producing a hosted video (see failure_reason no_deliverable).
Pipeline readiness
Section titled “Pipeline readiness”| Pipeline | requires | stability | Hosted delivery |
|---|---|---|---|
animated-explainer | — | production | certified |
screen-demo | — | production | experimental |
cinematic | — | production | experimental |
animation | — | production | experimental |
hybrid | — | production | experimental |
avatar-spokesperson | — | production | experimental |
talking-head | source_url | beta | experimental |
clip-factory | source_url | beta | experimental |
podcast-repurpose | source_url | beta | experimental |
localization-dub | source_url | beta | experimental |
documentary-montage | — | beta | experimental |
character-animation | — | beta (v0.1) | experimental |
framework-smoke | — | beta (test only) | experimental |
requires— a non-empty value (e.g.source_url) means the pipeline transforms existing media and cannot run brief-only. Submitting without it is rejected up-front with400 missing_required_inputand no credits held (and while batch is off,503 batch_unavailableis returned first). In a session (which takes only a free-text brief), put the source URL inside the brief.stability— engine maturity, independent of delivery. Aproductionengine pipeline (e.g.cinematic) can still be experimental for hosted delivery.- Hosted delivery — whether it reliably returns a hosted, shareable
video_url. Onlyanimated-explaineris certified. Fetch the live catalog any time via MCPlist_pipelinesorPIPELINE_INFOin the API.
3. The credit / budget / hold model
Section titled “3. The credit / budget / hold model”- Open a session with a
budget_credits(or accept the per-pipeline default). This places a hold on that many credits — they are reserved, not yet spent. - Each turn captures its actual orchestration spend from the budget (
credits.spentgrows,credits.heldshrinks). - Close the session (or let it expire) to release the unspent hold back to your balance. On a normal close, captured spend is not refunded (only the unspent hold is released).
- System-caused vs user-caused failures decide refunds (the M3 taxonomy). A turn that fails for a
system reason (
engine_error,no_deliverable, or a transientinfra_errorthat captured nothing) auto-refunds its captured spend — a compensating ledger entry that nets the turn to zero cost (you didn’t get what you paid for, so you get those credits back). A user-caused failure (user_interrupted,budget_exceeded) is billed for real and never refunded. The refund is idempotent (keyed to the turn:refund:system:turn:<id>) and theturn.error/turn.no_deliverableevents carryrefunded_credits. On a mid-session failure the hold is not released — the session stays live so you can retry.
The credits object on every session poll: { "budget": 500, "held": 460, "spent": 40 }. Read
your full ledger (holds/captures/releases) via GET /v1/credits.
4. Interactive session — the REST happy path
Section titled “4. Interactive session — the REST happy path”4.1 Open a session
Section titled “4.1 Open a session”curl -sX POST $BASE/v1/sessions \ -H "authorization: Bearer $KEY" -H "content-type: application/json" \ -H "idempotency-key: drpost-sess-42" \ -d '{"pipeline":"animated-explainer","budget_credits":500,"format":"9:16", "brief":"Explain how our invoicing API works, 60s, friendly"}'format is optional ("16:9" default | "9:16" vertical — the only two certified today) and is
fixed for the whole session — the delivered video comes out in that aspect. Omit it for landscape.
201 Created (captured live; a replay of the same idempotency-key returns the existing session as
200 with "replayed": true):
{ "id": "9ab692f2-8082-4e26-81ac-60bc1aa10514", "status": "open", "pipeline": "animated-explainer", "format": "9:16", "current_stage": null, "autopilot": false, "byok": false, "credits": { "budget": 500, "held": 500, "spent": 0 }, "pending_gate": null, "last_reply": null, "last_reply_stale": false, "failure_reason": null, "last_heartbeat_at": null, "video_url": null, "created_at": "2026-07-09T02:28:53.414Z", "updated_at": "2026-07-09T02:28:53.414Z", "replayed": false}Keep the id — it is the session id used by every subsequent call.
You can also open with no
briefand send it as the first message. If you passed abriefon open, it is recorded but the engine does not start work until yousend_message.
4.2 Send a message (start / advance a turn)
Section titled “4.2 Send a message (start / advance a turn)”curl -sX POST $BASE/v1/sessions/$SID/messages \ -H "authorization: Bearer $KEY" -H "content-type: application/json" \ -d '{"message":"Explain how our invoicing API works, 60s, friendly tone"}'202 Accepted — the turn runs asynchronously; this does not wait for it:
{ "turn_id": "…", "status": "running", "replayed": false }Notes:
- Only one turn runs at a time. Sending again while a turn is
runningreturns that same running turn (200), never a second turn. - Pass an
Idempotency-Keyheader to make a retry safe (a replay returns the same turn). 409 { "error": "gate_pending" }if a gate is unresolved — resolve it first.409 { "error": "session_closed" | "session_expired" | "session_failed" }if the session is terminal — open a new one.400 { "error": "invalid_request", "detail": … }if the message is empty or over the length cap.
4.3 Poll GET /v1/sessions/:id and branch on the result
Section titled “4.3 Poll GET /v1/sessions/:id and branch on the result”This is the heart of the integration. After every send_message / gate resolve, poll the session
until status leaves running, then branch:
Lower-latency alternative — SSE.
GET /v1/sessions/:id/eventsstreams the same lifecycle as Server-Sent Events (~2s latency):turn.start,turn.reply, gate/failure events, heartbeats. The stream closes after ~270s (Railway edge cap) — reconnect with theLast-Event-IDheader to resume without gaps, and close/branch when the session leavesrunning. Auth is header-only — native browserEventSourcecannot sendAuthorizationand there is no query-param key auth by design; from a browser usefetch()streaming with the header, or poll. Polling stays the simplest correct loop; SSE is an optimization, not a requirement. There are no webhooks today — a storedwebhook_urlis accepted but never called.
| Observed | Meaning | Do |
|---|---|---|
status:"running" | A turn is executing | Keep polling. last_heartbeat_at should advance; if it stalls >~180s the server reaps the turn as infra_error |
status:"awaiting_gate", pending_gate.stage != "budget" | A review decision is needed | Read pending_gate (+ get_artifact), then POST …/gates/:gateId |
status:"awaiting_gate", pending_gate.stage == "budget" | Out of budget | POST …/budget (not a gate resolve) |
status:"open", failure_reason == null | The turn was a conversational reply | Read last_reply; send another message |
status:"open" / "awaiting_gate", failure_reason != null | The turn FAILED (status is not "failed") | Recover per failure_reason — see §5 |
status:"closed" | Done | Get the video (§4.5) |
status:"failed" | The session failed to open (insufficient credits) | Terminal — top up + open a new session |
status:"expired" | Idle 72h; hold released | Terminal — open a new session |
Critical: a turn failure never sets
status:"failed". Detect a failed turn by readingfailure_reason(andlast_reply_stale), never bystatus.status:"failed"means only that the session could not open.
A running poll (captured live shape, with a live turn last_heartbeat_at would be non-null):
{ "id": "9ab692f2-…", "status": "running", "pipeline": "animated-explainer", "current_stage": "research", "credits": { "budget": 500, "held": 500, "spent": 0 }, "pending_gate": null, "last_reply": null, "last_reply_stale": false, "failure_reason": null, "last_heartbeat_at": "2026-07-09T02:29:10.522Z", "video_url": null, "created_at": "…", "updated_at": "…"}4.4 Resolve gates
Section titled “4.4 Resolve gates”At awaiting_gate, pending_gate looks like:
"pending_gate": { "gate_id": "…", "stage": "script", "question": "Approve this script, or tell me what to change?", "options": ["approve", "revise"], "artifact_names": ["script"], "artifact_excerpt": "SCENE 1 — …first ~2KB of the artifact… …[truncated]"}Be defensive: question and artifact_excerpt may be null, and artifact_names may be
[]. Any name that is listed resolves via GET …/artifacts/:name; the excerpt (when present)
is usually enough to decide without that call.
Resolve it (this resumes the session — starts a new turn, so poll again):
# approvecurl -sX POST $BASE/v1/sessions/$SID/gates/$GID \ -H "authorization: Bearer $KEY" -H "content-type: application/json" \ -d '{"decision":"approve"}'# revise (feedback REQUIRED)curl -sX POST $BASE/v1/sessions/$SID/gates/$GID \ -H "authorization: Bearer $KEY" -H "content-type: application/json" \ -d '{"decision":"revise","feedback":"Make scene 2 shorter and add our logo"}'→ 202 { "turn_id": "…", "status": "running" }. A gate resolves once; a second call →
409 { "error": "gate_not_pending" }.
Budget gate (pending_gate.stage == "budget", options:["add_budget"]) is different — clear it
with a top-up, not a gate resolve. After the top-up the session returns to open but does NOT
auto-resume — send the next message (e.g. {"message":"continue"}) to start a new turn; the same
rule applies after a budget_exceeded turn failure. Idempotency-Key is required so a retried
top-up holds credits exactly once:
curl -sX POST $BASE/v1/sessions/$SID/budget \ -H "authorization: Bearer $KEY" -H "content-type: application/json" \ -H "idempotency-key: drpost-topup-1" \ -d '{"additional_credits":300}'# → 200 sessionView (gate cleared, status back to "open") | 402 insufficient_credits4.5 Retrieve the video
Section titled “4.5 Retrieve the video”video_url is resolved on read and appears as soon as a hosted render succeeds — you do not
have to wait for status:"closed". It is non-null iff a gateway-resolvable video exists. Two
equivalent ways to fetch it:
# get_video by session id (captured live shape):curl -s $BASE/v1/videos/$SID -H "authorization: Bearer $KEY"# → { "session_id":"…", "status":"closed", "video_url":"…", "ready": true,# "format":"9:16", "width":1080, "height":1920, "aspect_ratio":"9:16" }format is the aspect the session ordered ("16:9" | "9:16" | null); width/height are
the actual delivered pixels and aspect_ratio is derived from them (by gcd, e.g. "9:16") — all
null until a hosted render has succeeded. This lets a downstream poster (Dr.Post) route the file per
platform without probing it: aspect_ratio:"9:16" → TikTok / Instagram Reels / YouTube Shorts;
"16:9" → a landscape YouTube upload. Order a vertical video up front with format:"9:16" on
open_session (see §4.1) — it is fixed for the session, so every render comes out in that frame.
A closed session with a delivered video:
{ "id": "…", "status": "closed", "pipeline": "animated-explainer", "current_stage": "compose", "credits": { "budget": 500, "held": 0, "spent": 466 }, "pending_gate": null, "video_url": "https://api.macaroni.video/v1/videos/share/AbC…xyz", "failure_reason": null, "…": "…"}The video_url:
- Is an absolute
httpsURL:…/v1/videos/share/<token>. - Streams the MP4 bytes through the gateway (supports HTTP
Rangefor seeking). The underlying storage is never exposed — you never see an S3/AWS URL. - Is stable, unguessable, and
noindex, nofollow— safe to hand to an end user. The token is the credential (no API key needed to play it); treat it like a share secret. - On a bad
Rangethe share route returns416 { "error": "range_not_satisfiable" }; an unknown token or a not-yet-succeeded render returns404.
AI labeling is the poster’s job at post time. Every delivered MP4 is AI-generated and ships with machine-readable AI-provenance metadata baked into the file (container
comment+descriptiontags — no visible watermark; verify withffprobe -v quiet -show_entries format_tags=comment,description -of default out.mp4). That embedded tag satisfies our minimal mandatory labeling, but it is not a platform disclosure: when Dr.Post (or any downstream poster) publishes the video to TikTok, Instagram Reels, or YouTube Shorts, it must set that platform’s own “AI-generated / altered content” flag at post time — Macaroni cannot set it for you. Treat it as a required field in your publish step.
4.6 Close the session
Section titled “4.6 Close the session”Always close when done (or to abandon) — it releases the unspent hold:
curl -sX POST $BASE/v1/sessions/$SID/close -H "authorization: Bearer $KEY"# → 200 sessionView with credits.held == 0, status "closed" (captured live)close is idempotent. If you never close, the session auto-expires after 72h idle and the hold
is released then. To abort a turn that is taking too long or going the wrong way,
POST /v1/sessions/:id/interrupt (stop button — settles the running turn as
failure_reason:"user_interrupted", bills the turn’s actual model spend up to the stop (a
user-caused stop, never refunded), returns the session to open).
5. Errors & failure model
Section titled “5. Errors & failure model”5.1 HTTP status codes
Section titled “5.1 HTTP status codes”Two distinct 400 shapes:
- Validation (
invalid_request):{ "error": "invalid_request", "detail": { "formErrors": […], "fieldErrors": {…} } }— a malformed body or a free-text field over the length cap. - Handler (
{ "error": "<code>" }): a semantic rejection past validation, e.g.idempotency_key_required.
| Status | Codes | Recovery |
|---|---|---|
400 | invalid_request (+detail) · idempotency_key_required · missing_required_input (+missing) | fix the body / add the header; missing_required_input = a source-required batch pipeline submitted without its input key — no credits held |
401 | missing_api_key · invalid_api_key | fix the Authorization header |
402 | insufficient_credits (+required_credits,available_credits) | top up, then retry |
403 | forbidden | your key lacks the scope (e.g. /admin) |
404 | not_found | check the id (scoped to your account); share route: unknown/unready token |
409 | not_cancelable · gate_pending · gate_not_pending · session_closed / session_expired / session_failed | resolve the gate / open a new session |
416 | range_not_satisfiable | share route only — fix the Range header |
429 | rate_limited · concurrency_limit (+retry_after_seconds, Retry-After) | back off retry_after_seconds, then retry |
503 | batch_unavailable (+hint) | POST /v1/jobs while batch is disabled (the default) — no credits held; use the interactive session flow (§4) |
5.2 failure_reason (typed turn failures) — the table to code against
Section titled “5.2 failure_reason (typed turn failures) — the table to code against”When a poll shows failure_reason != null, the latest turn failed (the session status is
open/awaiting_gate, not failed). Every reason carries a class that decides money:
system-caused failures auto-refund their captured credits; user-caused failures are
billed and never refunded. Branch on the value:
failure_reason | Class | Billed / Refunded | What happened | Recovery |
|---|---|---|---|---|
infra_error | system | Not billed (nothing captured → nothing to refund) | Transient infrastructure failure, or a running turn whose heartbeat lapsed ~180s and was reaped | Resend the same message — it’s safe, nothing was charged |
engine_error | system | Captured, then auto-refunded → net zero | The engine erred mid-turn | Retry; adjust the brief if it persists |
no_deliverable | system | Captured, then auto-refunded → net zero | The turn finished but produced no hosted, deliverable video (e.g. the engine rendered locally and never dispatched a hosted render) | Retry; if it persists, the pipeline/brief may not be supported — prefer animated-explainer (the only pipeline certified for hosted delivery) |
budget_exceeded | user | Billed up to the cap — not refunded | The session budget was exhausted before the turn finished (surfaces as a stage:"budget" gate) | POST …/budget, then continue |
user_interrupted | user | Billed the turn’s actual model spend up to the stop — not refunded | You called interrupt | Session is back to open — continue or close |
A BYOK session that brings its own Anthropic key dials Anthropic directly on your key, so an interrupt on such a turn bills 0 for the LLM line (your provider, your money) — the platform meters nothing to charge.
The same information is in the readable conversation (GET …/messages): the failure appears as a
message of type turn.error / turn.reaped / turn.no_deliverable / turn.interrupted /
budget.exceeded, each carrying data.failure_reason + data.failure_class ("user"/"system") +
a human data.message. The turn.error (system-class) and turn.no_deliverable events additionally
carry data.refunded_credits (the amount auto-refunded).
5.3 Poll-loop pseudocode
Section titled “5.3 Poll-loop pseudocode”def run_session(brief, pipeline="animated-explainer", budget=500): sid = POST("/v1/sessions", {"pipeline": pipeline, "budget_credits": budget}, idem="sess-"+uuid())["id"] try: POST(f"/v1/sessions/{sid}/messages", {"message": brief}) while True: s = poll_until_not_running(sid) # GET /v1/sessions/:id, sleep+retry while running
if s["status"] == "closed": v = GET(f"/v1/videos/{sid}") return v["video_url"] if v["ready"] else None
if s["status"] in ("failed", "expired"): raise Terminal(s["status"]) # session ended; nothing to recover in place
if s["failure_reason"]: # a TURN failed (status is open/awaiting_gate) fr = s["failure_reason"] if fr == "infra_error": POST(f"/v1/sessions/{sid}/messages", {"message": brief}) # safe resend continue if fr == "budget_exceeded": POST(f"/v1/sessions/{sid}/budget", {"additional_credits": budget}, idem="topup-"+uuid()) POST(f"/v1/sessions/{sid}/messages", {"message": "continue"}); continue raise TurnFailed(fr) # engine_error / no_deliverable → surface/retry
g = s["pending_gate"] if s["status"] == "awaiting_gate" and g: if g["stage"] == "budget": POST(f"/v1/sessions/{sid}/budget", {"additional_credits": budget}, idem="topup-"+uuid()); continue decision = review(s, g) # your logic; g.artifact_excerpt or GET artifact POST(f"/v1/sessions/{sid}/gates/{g['gate_id']}", decision) # {"decision":"approve"} … continue
if s["status"] == "open": # conversational reply, no failure # inspect s["last_reply"]; send the next instruction, or break to finish ... finally: POST(f"/v1/sessions/{sid}/close") # release the unspent hold
# On any 429: read retry_after_seconds (or the Retry-After header) and sleep before retrying.Idempotency: pass a stable Idempotency-Key on open, on send_message, and (required) on
budget. A replay returns the existing session/turn and is never rate-limited.
6. Limits (rate, concurrency, message cap)
Section titled “6. Limits (rate, concurrency, message cap)”Per-account ceilings bound volume (a cost/DoS gate), enforced identically on REST and MCP:
| Limit | Default | Env override |
|---|---|---|
| Concurrent turns/sandboxes running at once | 3 | MACARONI_MAX_CONCURRENT_TURNS |
| New sessions per rolling minute | 10/min | MACARONI_SESSIONS_PER_MINUTE |
| New sessions per rolling hour | 100/hour | MACARONI_SESSIONS_PER_HOUR |
Free-text length cap (brief/message/feedback) | 16 000 chars | MACARONI_MESSAGE_MAX_CHARS |
- Tripping a rate/concurrency limit →
429with{ "error": "rate_limited" | "concurrency_limit", "retry_after_seconds": <n>, "hint": "…" }and aRetry-After: <n>header (REST). Waitretry_after_seconds, then retry.rate_limited= session-creation window;concurrency_limit= too many turns running (poll your open sessions until one frees). - A
429on a gate resolve leaves the gate pending (retry-safe). An idempotent replay (sameIdempotency-Key, or a send while a turn already runs) is never limited. - Over the length cap →
400 { "error": "invalid_request", … }before any turn is dispatched.
7. MCP interface (for agent runtimes)
Section titled “7. MCP interface (for agent runtimes)”If drpost drives Macaroni from an MCP-capable agent instead of raw REST, the same capability is exposed as MCP tools. REST ⇄ MCP are 1:1 across the integrator surface; the tool shapes match the REST bodies above.
- Endpoint:
POST https://api.macaroni.video/mcp - Transport: stateless Streamable HTTP — one request/response per
POST; no session id.GET/DELETE /mcp→405. Do not depend on anmcp-session-idheader (none is emitted). - Auth: the same
Authorization: Bearer <key>. Unauthenticated → JSON-RPC-32001. - Framing: the server replies SSE-framed even for a single response — send
Accept: application/json, text/event-streamand parse thedata:line.
Handshake and a call, captured live:
# 1) initializecurl -s $BASE/mcp -H "authorization: Bearer $KEY" -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize", "params":{"protocolVersion":"2025-06-18","capabilities":{}, "clientInfo":{"name":"drpost","version":"1.0"}}}'# → event: message# data: {"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":true}},# "serverInfo":{"name":"macaroni-mcp-server","version":"0.1.0"},# "instructions":"Macaroni generates videos two ways. Pick ONE: …"},"jsonrpc":"2.0","id":1}
# 2) (per the MCP spec) send the initialized notification:# {"jsonrpc":"2.0","method":"notifications/initialized"}
# 3) discover toolscurl -s $BASE/mcp -H "authorization: Bearer $KEY" -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
# 4) call a toolcurl -s $BASE/mcp -H "authorization: Bearer $KEY" -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":3,"method":"tools/call", "params":{"name":"open_session","arguments":{"pipeline":"animated-explainer","brief":"…"}}}'Tools (live list): list_pipelines, get_balance, submit_job, get_job, list_jobs,
cancel_job, open_session, send_message, get_session, get_messages, resolve_gate,
add_budget, get_artifact, get_video, list_videos, get_credits, interrupt,
close_session.
- Every tool carries MCP annotations (
readOnlyHint/destructiveHint/idempotentHint/openWorldHint). - Errors come back as
isErrorresults (not JSON-RPC protocol errors) with the same body as the REST error — including a429-equivalent{ "error": "rate_limited"|"concurrency_limit", "retry_after_seconds", "hint" }(there is no HTTP header over MCP, so readretry_after_seconds). - The MCP mapping of the REST budget
Idempotency-Keyis theclient_tokenargument onadd_budget/send_message.
The interactive loop is identical to §4: open_session → send_message → poll get_session
(same states) → resolve_gate / add_budget → get_video → close_session.
8. Batch flow (submit_job) — CURRENTLY UNAVAILABLE
Section titled “8. Batch flow (submit_job) — CURRENTLY UNAVAILABLE”Batch job processing is disabled today. POST /v1/jobs (and the MCP submit_job tool) returns
503 { "error": "batch_unavailable", "hint": … } with no credits held — no worker drains
the job queue. Use the interactive session flow (§4)
for a delivered video; it is the only working path today. Batch re-enables only when a worker is
stood up and MACARONI_BATCH_ENABLED=1 is set (the flag is off by default — a deliberate
gate, not a bug).
curl -sX POST $BASE/v1/jobs \ -H "authorization: Bearer $KEY" -H "content-type: application/json" \ -H "idempotency-key: drpost-job-7" \ -d '{"pipeline":"animated-explainer","input":{"brief":"How photosynthesis works, 60s"}}'# → 503 { "error": "batch_unavailable", "hint": "…use the interactive session API…" } (no credits held)When batch is enabled (not today), the flow is: POST /v1/jobs → 202 { …jobView, status:"queued", status_url, events_url, cancel_url } → poll GET /v1/jobs/:id until status is
terminal (succeeded → result.url; failed → error). Caveats to design around even then:
autopilot=falseis unsupported — the job lands inawaiting_approvalwith no resume endpoint, holding credits. Only ever submit batch withautopilot=true(the default), or use a session.- Source-required pipelines need their
inputkey (e.g.input.source_url) — a submit without it is rejected up-front with400 missing_required_input, no credits held. webhook_urlis reserved / no-op — accepted and stored but never delivered. Poll; do not wait on a webhook.- Hosted delivery is still only certified for
animated-explainer.
9. Integration checklist
Section titled “9. Integration checklist”- Store the API key server-side; send it as
Authorization: Beareron every call. - Prefer interactive sessions +
animated-explainerfor a reliable hosted video. - Implement the poll loop (§5.3): branch on
statusandfailure_reason— never treatstatus:"failed"as “the turn failed”. - Handle every
failure_reason(§5.2) by class: system-caused (infra_errorsafe resend,engine_error/no_deliverableauto-refunded → retry) vs user-caused (budget_exceeded,user_interrupted— billed, never refunded). - Send an
Idempotency-Keyonopenandsend_message; it is required onbudget. - Respect
429— back offretry_after_seconds/Retry-After. - Distinguish a budget gate (
stage:"budget"→…/budget) from a review gate (…/gates/:id). - Always
closesessions to release the hold (or rely on the 72h idle expiry). - Serve the
video_urlshare link directly to users — it streams through the gateway, isnoindex, and needs no API key.