Skip to content

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 /admin provisioning 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-explainer pipeline 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:

    1. Download: macaroni-mcp.zip (MCP agents, e.g. Claude Code) · macaroni-api.zip (REST integrators). Browse first: SKILL.md.
    2. 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/skills

    Windows PowerShell (curl.exe/tar ship 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/skills

    skills CLI from the tarball: curl -sL https://macaroni.video/skills/macaroni-mcp.tgz | tar -xz -C /tmp && npx skills add /tmp/macaroni-mcp -y (swap macaroni-mcp for macaroni-api in 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.


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.mdAdmin / 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.mdGet 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:

Terminal window
curl -s https://api.macaroni.video/health
# → {"ok":true,"service":"macaroni-gateway","ts":"2026-07-09T02:27:35.831Z"}

Check your balance before spending:

Terminal window
curl -s $BASE/v1/credits -H "authorization: Bearer $KEY"
# → { "available_credits": 3263, "ledger": [ … ] }

Interactive session (/v1/sessions)Batch job (/v1/jobs)
AvailabilityLIVE — the only currently-working path to a delivered videoCURRENTLY UNAVAILABLE — returns 503 batch_unavailable, no credits held (no worker; MACARONI_BATCH_ENABLED off by default)
ModelMulti-turn conversation with approval gatesFire-and-forget, no gates
Review / steerYes — approve/revise the script & scenesNo
ApprovalsYou resolve gates yourselfautopilot=false is a dead-end (no resume endpoint) — use only autopilot=true
Recommended for drpostYes — use thisNot usable today (see §8)
CreditsHolds a budget, captures per turn, refunds the rest on closeConservative hold, reconciled on completion (when enabled)

Build against the interactive session flow only. Batch (/v1/jobs / MCP submit_job) is disabled and returns 503 batch_unavailable with 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).

PipelinerequiresstabilityHosted delivery
animated-explainerproductioncertified
screen-demoproductionexperimental
cinematicproductionexperimental
animationproductionexperimental
hybridproductionexperimental
avatar-spokespersonproductionexperimental
talking-headsource_urlbetaexperimental
clip-factorysource_urlbetaexperimental
podcast-repurposesource_urlbetaexperimental
localization-dubsource_urlbetaexperimental
documentary-montagebetaexperimental
character-animationbeta (v0.1)experimental
framework-smokebeta (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 with 400 missing_required_input and no credits held (and while batch is off, 503 batch_unavailable is 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. A production engine pipeline (e.g. cinematic) can still be experimental for hosted delivery.
  • Hosted delivery — whether it reliably returns a hosted, shareable video_url. Only animated-explainer is certified. Fetch the live catalog any time via MCP list_pipelines or PIPELINE_INFO in the API.

  • 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.spent grows, credits.held shrinks).
  • 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 transient infra_error that 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 the turn.error / turn.no_deliverable events carry refunded_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”
Terminal window
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 brief and send it as the first message. If you passed a brief on open, it is recorded but the engine does not start work until you send_message.

4.2 Send a message (start / advance a turn)

Section titled “4.2 Send a message (start / advance a turn)”
Terminal window
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 running returns that same running turn (200), never a second turn.
  • Pass an Idempotency-Key header 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/events streams 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 the Last-Event-ID header to resume without gaps, and close/branch when the session leaves running. Auth is header-only — native browser EventSource cannot send Authorization and there is no query-param key auth by design; from a browser use fetch() 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 stored webhook_url is accepted but never called.

ObservedMeaningDo
status:"running"A turn is executingKeep 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 neededRead pending_gate (+ get_artifact), then POST …/gates/:gateId
status:"awaiting_gate", pending_gate.stage == "budget"Out of budgetPOST …/budget (not a gate resolve)
status:"open", failure_reason == nullThe turn was a conversational replyRead last_reply; send another message
status:"open" / "awaiting_gate", failure_reason != nullThe turn FAILED (status is not "failed")Recover per failure_reason — see §5
status:"closed"DoneGet 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 releasedTerminal — open a new session

Critical: a turn failure never sets status:"failed". Detect a failed turn by reading failure_reason (and last_reply_stale), never by status. 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": ""
}

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

Terminal window
# approve
curl -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:

Terminal window
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_credits

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:

Terminal window
# 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 https URL: …/v1/videos/share/<token>.
  • Streams the MP4 bytes through the gateway (supports HTTP Range for 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 Range the share route returns 416 { "error": "range_not_satisfiable" }; an unknown token or a not-yet-succeeded render returns 404.

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 + description tags — no visible watermark; verify with ffprobe -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.

Always close when done (or to abandon) — it releases the unspent hold:

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


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.
StatusCodesRecovery
400invalid_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
401missing_api_key · invalid_api_keyfix the Authorization header
402insufficient_credits (+required_credits,available_credits)top up, then retry
403forbiddenyour key lacks the scope (e.g. /admin)
404not_foundcheck the id (scoped to your account); share route: unknown/unready token
409not_cancelable · gate_pending · gate_not_pending · session_closed / session_expired / session_failedresolve the gate / open a new session
416range_not_satisfiableshare route only — fix the Range header
429rate_limited · concurrency_limit (+retry_after_seconds, Retry-After)back off retry_after_seconds, then retry
503batch_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_reasonClassBilled / RefundedWhat happenedRecovery
infra_errorsystemNot billed (nothing captured → nothing to refund)Transient infrastructure failure, or a running turn whose heartbeat lapsed ~180s and was reapedResend the same message — it’s safe, nothing was charged
engine_errorsystemCaptured, then auto-refunded → net zeroThe engine erred mid-turnRetry; adjust the brief if it persists
no_deliverablesystemCaptured, then auto-refunded → net zeroThe 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_exceededuserBilled up to the cap — not refundedThe session budget was exhausted before the turn finished (surfaces as a stage:"budget" gate)POST …/budget, then continue
user_interrupteduserBilled the turn’s actual model spend up to the stopnot refundedYou called interruptSession 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).

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:

LimitDefaultEnv override
Concurrent turns/sandboxes running at once3MACARONI_MAX_CONCURRENT_TURNS
New sessions per rolling minute10/minMACARONI_SESSIONS_PER_MINUTE
New sessions per rolling hour100/hourMACARONI_SESSIONS_PER_HOUR
Free-text length cap (brief/message/feedback)16 000 charsMACARONI_MESSAGE_MAX_CHARS
  • Tripping a rate/concurrency limit → 429 with { "error": "rate_limited" | "concurrency_limit", "retry_after_seconds": <n>, "hint": "…" } and a Retry-After: <n> header (REST). Wait retry_after_seconds, then retry. rate_limited = session-creation window; concurrency_limit = too many turns running (poll your open sessions until one frees).
  • A 429 on a gate resolve leaves the gate pending (retry-safe). An idempotent replay (same Idempotency-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.

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 /mcp405. Do not depend on an mcp-session-id header (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-stream and parse the data: line.

Handshake and a call, captured live:

Terminal window
# 1) initialize
curl -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 tools
curl -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 tool
curl -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 isError results (not JSON-RPC protocol errors) with the same body as the REST error — including a 429-equivalent { "error": "rate_limited"|"concurrency_limit", "retry_after_seconds", "hint" } (there is no HTTP header over MCP, so read retry_after_seconds).
  • The MCP mapping of the REST budget Idempotency-Key is the client_token argument on add_budget / send_message.

The interactive loop is identical to §4: open_sessionsend_message → poll get_session (same states) → resolve_gate / add_budgetget_videoclose_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).

Terminal window
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/jobs202 { …jobView, status:"queued", status_url, events_url, cancel_url } → poll GET /v1/jobs/:id until status is terminal (succeededresult.url; failederror). Caveats to design around even then:

  • autopilot=false is unsupported — the job lands in awaiting_approval with no resume endpoint, holding credits. Only ever submit batch with autopilot=true (the default), or use a session.
  • Source-required pipelines need their input key (e.g. input.source_url) — a submit without it is rejected up-front with 400 missing_required_input, no credits held.
  • webhook_url is 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.

  • Store the API key server-side; send it as Authorization: Bearer on every call.
  • Prefer interactive sessions + animated-explainer for a reliable hosted video.
  • Implement the poll loop (§5.3): branch on status and failure_reason — never treat status:"failed" as “the turn failed”.
  • Handle every failure_reason (§5.2) by class: system-caused (infra_error safe resend, engine_error / no_deliverable auto-refunded → retry) vs user-caused (budget_exceeded, user_interrupted — billed, never refunded).
  • Send an Idempotency-Key on open and send_message; it is required on budget.
  • Respect 429 — back off retry_after_seconds / Retry-After.
  • Distinguish a budget gate (stage:"budget"…/budget) from a review gate (…/gates/:id).
  • Always close sessions to release the hold (or rely on the 72h idle expiry).
  • Serve the video_url share link directly to users — it streams through the gateway, is noindex, and needs no API key.