---
name: macaroni-mcp
description: >-
  Drive Macaroni — a metered AI video-generation platform — from any MCP client to produce a
  hosted, shareable video through a multi-turn conversation with approval gates. Use this whenever
  you are (or should be) connected to the Macaroni MCP server (a POST /mcp Streamable-HTTP endpoint,
  Authorization Bearer mac_… key) and the user wants to make/produce/generate a video, open or
  advance a Macaroni session, poll a running turn, approve or revise an approval gate, top up a
  budget gate, check credits, or recover from a failed turn — even if they just say "make me a
  video", "ask Macaroni", or "use the video MCP" without naming the protocol. Covers the 18 tools,
  the open_session → send_message → get_session → resolve_gate → get_video happy path, poll
  discipline, budget gates, the failure_reason recovery table, and the credits model.
---

# Macaroni over MCP

> **Install & key:** this skill is installable via
> `mkdir -p .claude/skills && curl -sL https://macaroni.video/skills/macaroni-mcp.tgz | tar -xz -C .claude/skills`
> (served from macaroni.video — a skill is just a folder your agent reads). Get a `mac_…` key by signing up self-serve at
> https://app.macaroni.video (email magic link) — your `default` key is minted at signup.

Macaroni turns a creative brief into a **hosted, shareable video** through a multi-turn
conversation. You act like a caring client: describe the video, iterate over turns, approve or
revise at gates, then collect a share link. It is exposed as MCP **tools** — the same engine and
billing that back the REST API (the sibling `macaroni-api` skill / `docs/public/API.md`), mapped 1:1.

**Everything is async and metered.** You submit, then **poll**; there is no synchronous "give me a
video" call. Costs are always integer **credits**, never dollars.

## Set the base URL first

Every call targets your Macaroni gateway; the MCP endpoint is `<BASE>/mcp`.

- **Today:** `https://api.macaroni.video`
- **After the DNS cutover:** `https://api.macaroni.video`

Use whichever your operator gave you. This skill writes it as `$BASE`.

## Connect

**Claude Code (CLI):**
```bash
claude mcp add --transport http macaroni \
  "$BASE/mcp" \
  --header "Authorization: Bearer $MAC_KEY"
```

**Config file (Claude Desktop / other MCP clients):**
```jsonc
{
  "mcpServers": {
    "macaroni": {
      "type": "http",
      "url": "https://api.macaroni.video/mcp",
      "headers": { "Authorization": "Bearer YOUR_API_KEY" }
    }
  }
}
```

The transport is **stateless Streamable HTTP** — one request/response per call, no session to
resume. Auth is `Authorization: Bearer <mac_… key>` (the same key as the REST API); an
unauthenticated call gets JSON-RPC error `-32001`. Wire-level framing details (the server replies
SSE-framed even for one response; `GET`/`DELETE /mcp` → 405) are in [reference.md](reference.md) —
you rarely need them because your MCP client handles framing.

**The exact argument schema for every tool is its `inputSchema` (from `tools/list`).** This skill
teaches the *flow and semantics*; when you need precise field names, read the tool's schema — it is
authoritative and self-describing.

## The one happy path

This is the **only currently-working path to a delivered video**. Do not build around `submit_job`
(batch is disabled — see below).

1. **`list_pipelines`** → pick a pipeline `id` and adapt its `example_input`. **Prefer
   `animated-explainer`** — it is the only pipeline **certified for hosted delivery** today (the
   honesty markers explain why: see [Pipelines & honesty markers](#pipelines--honesty-markers)). A
   session takes only free text, so if a pipeline `requires` a source (e.g. `source_url`), put that
   URL **inside the brief**.
2. **`get_balance`** (or `get_credits` for the full ledger) → confirm you have enough credits.
3. **`open_session`** `{ pipeline: "animated-explainer", brief: "…", budget_credits?: 500 }` →
   holds a credit budget and returns a session view with an `id`. Keep the `id`.
4. **`send_message`** `{ session_id, message: "…" }` → starts a turn and returns immediately
   (async). Then **poll `get_session`** (next section) until `status` leaves `running`, branching on
   what you find: a conversational reply → read `last_reply`, send the next message; an
   `awaiting_gate` → resolve it; a failed turn → recover by `failure_reason`. Repeat until
   `status:"closed"`.
5. **`get_video`** (or read `video_url` off any poll) → the finished **share link**. Then
   **`close_session`** to release the unspent hold.

## Poll discipline

After every `send_message` or gate resolution, call **`get_session`** on an interval of **2–5
seconds** until `status` is no longer `"running"`. `get_session` is the canonical poll target; it
returns `status`, `current_stage`, `credits`, `pending_gate`, `last_reply`, `last_reply_stale`,
`failure_reason`, `last_heartbeat_at`, and `video_url`.

- While `running`, `last_heartbeat_at` should keep advancing. It is **null until the first ping** —
  fall back to `updated_at`. A running turn whose heartbeat lapses ~180s (3 min) is reaped by the
  server as `failure_reason:"infra_error"` (just resend).
- Don't hammer it — a running turn does real model work that takes many seconds to minutes.

**Branch on the poll** (read `status` **and** `failure_reason` — they are independent):

| Observed | Meaning | Do |
|---|---|---|
| `status:"running"` | A turn is executing | Keep polling (2–5s) |
| `status:"awaiting_gate"`, gate stage ≠ `budget` | A review decision is needed | Read `pending_gate` → `resolve_gate` |
| `status:"awaiting_gate"`, gate stage = `budget` | Out of budget | `add_budget` (not `resolve_gate`), then `send_message` ("continue") — no auto-resume |
| `status:"open"`, `failure_reason` = null | Turn was a conversational reply | Read `last_reply`; `send_message` again |
| `status:"open"`/`"awaiting_gate"`, `failure_reason` ≠ null | **The turn FAILED** | Recover per the [failure table](#failure_reason-recovery) |
| `status:"closed"` | Done | `get_video` / read `video_url` |
| `status:"failed"` | The session could not **open** (insufficient credits) | Terminal — top up, `open_session` again |
| `status:"expired"` | Idle 72h; hold released | Terminal — `open_session` again |

> **The single most important gotcha:** a *turn* failure does **not** set `status:"failed"`. The
> session stays `open` (or `awaiting_gate`) with `failure_reason` set. **Detect a failed turn by
> reading `failure_reason`, never `status`.** `status:"failed"` means only that the session never
> opened.

## Gates

At `awaiting_gate`, `get_session.pending_gate` is
`{ gate_id, stage, question, options, artifact_names, artifact_excerpt }`. Be defensive: `question`
and `artifact_excerpt` **may be null**, and `artifact_names` **may be empty**. The
`artifact_excerpt` (a ~2KB inline preview) is usually enough to decide with no extra call; any name
listed in `artifact_names` resolves to full content via **`get_artifact`**.

**Two kinds of gate — branch on `stage`/`options`:**

- **Review gate** (e.g. `stage:"script"`, `options:["approve","revise"]`) → **`resolve_gate`**
  `{ session_id, gate_id, decision: "approve" | "revise", feedback? }`. `revise` **requires**
  `feedback` (a `revise` without it returns an isError `invalid_request`). Resolving a gate
  **resumes** the session (starts a new turn) — poll again.
- **Budget gate** (`stage:"budget"`, `options:["add_budget"]`) → **`add_budget`**
  `{ session_id, additional_credits, client_token }`, **not** `resolve_gate`. `client_token` is a
  **required** idempotency key — the tool schema itself requires it, so a keyless call fails
  argument validation. Reuse the same `client_token` to safely retry the *same* top-up; use a new
  one for a separate top-up.

## failure_reason recovery

When a poll shows `failure_reason != null`, the **latest turn** failed. Every reason has a **class**
that decides billing: a **system**-caused failure auto-refunds the turn's captured credits (net zero
— you are not charged for what you didn't get); a **user**-caused failure is billed for real and
**never** refunded. Code against this table:

| `failure_reason` | Class | Billing | Recovery |
|---|---|---|---|
| `infra_error` | system | Not billed (nothing captured) | **Resend the same message** — safe, nothing was charged (also what a heartbeat-reaped turn reports) |
| `engine_error` | system | Captured, then **auto-refunded** → net zero | Retry; adjust the brief if it persists |
| `no_deliverable` | system | Captured, then **auto-refunded** → net zero | The turn finished but produced no hosted video. Retry; if it persists the pipeline/brief may not be supported — prefer `animated-explainer` |
| `budget_exceeded` | user | Billed up to the cap — not refunded | `add_budget`, then `send_message` ("continue") — top-ups never auto-resume |
| `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_session` |

The same information is in the readable conversation via **`get_messages`**: a failure appears as a
typed event (`turn.error` / `turn.reaped` / `turn.no_deliverable` / `turn.interrupted` /
`budget.exceeded`) carrying `data.failure_reason`, `data.failure_class`, and a human `data.message`
(system-class refunds also carry `data.refunded_credits`).

## Credits mental model

- **`open_session` places a hold** on `budget_credits` — reserved, not yet spent.
- **Each turn captures** its actual orchestration spend from the budget (`credits.spent` grows,
  `credits.held` shrinks). The poll's `credits` object is `{ budget, held, spent }`.
- **`close_session` releases the unspent hold** back to your balance. Captured spend is not refunded
  on a normal close — only the unspent hold is released.
- **System failures auto-refund** their capture (see the table); **user failures are billed**.
- Read the full ledger (holds/captures/releases/refunds) with **`get_credits`**; a quick balance
  with **`get_balance`**.

## Pipelines & honesty markers

`list_pipelines` returns each pipeline with a description, an `example_input`, and three honest
markers — Macaroni does not pretend everything works:

- **`requires`** — mandatory input keys. A non-empty value (e.g. `source_url`) means the pipeline
  transforms **existing media** and cannot run brief-only. In a session (free text only), put the
  source URL **in the brief**.
- **`stability`** — engine maturity (`production` | `beta`), independent of delivery.
- **hosted delivery** — whether it reliably returns a hosted `video_url`. **Only `animated-explainer`
  is certified today.** Every other pipeline is experimental: it may finish its creative work yet
  produce no hosted video, settling the turn as `failure_reason:"no_deliverable"` (credits
  auto-refunded). A `production` engine pipeline can still be *experimental* for hosted delivery —
  the two markers are independent. **For a guaranteed hosted video today, use `animated-explainer`.**

## Batch (`submit_job`) is CURRENTLY UNAVAILABLE

`submit_job` returns an **isError** result `{ error: "batch_unavailable", hint: … }` and holds **no
credits** — no worker drains the job queue today. `get_job` / `list_jobs` / `cancel_job` are the
batch read/cancel tools and are moot while batch is off. **Do not build an agent around
`submit_job`.** Use the interactive session flow above for a delivered video.

## The 18 tools at a glance

Session flow (what you use): `open_session`, `send_message`, `get_session` (poll target),
`get_messages`, `resolve_gate`, `add_budget`, `get_artifact`, `get_video`, `interrupt`,
`close_session`. Read-only account: `list_pipelines`, `get_balance`, `get_credits`, `list_videos`.
Batch (disabled): `submit_job`, `get_job`, `list_jobs`, `cancel_job`.

Every tool carries MCP annotations (`readOnlyHint` / `destructiveHint` / `idempotentHint` /
`openWorldHint`) so you can reason about safety, and errors come back as **isError** results (with a
`hint` and suggested next step), never protocol errors. Full per-tool semantics, the rate/
concurrency 429-equivalents, terminal-session handling, the `interrupt` stop-button details, and
BYOK (which is **REST-only** — there is no MCP tool to manage provider keys) are in
**[reference.md](reference.md)** — read it when you hit an edge case or need a field you don't see
here.

## Worked end-to-end example

An agent making a 60-second explainer. Tool calls shown at the semantic level (arguments in / object
out — your MCP client handles the JSON-RPC/SSE framing).

```
▶ list_pipelines {}
◀ [ { id:"animated-explainer", stability:"production", hosted_delivery:"certified",
      requires:[], example_input:{ brief:"Explain how photosynthesis works, 60s" } }, … ]

▶ get_balance {}
◀ { available_credits: 3263 }

▶ open_session { pipeline:"animated-explainer", budget_credits:500,
                 brief:"Explain how our invoicing API works, 60s, friendly" }
◀ { id:"9ab6…0514", status:"open", credits:{budget:500,held:500,spent:0},
    pending_gate:null, last_reply:null, failure_reason:null, video_url:null }

▶ send_message { session_id:"9ab6…0514",
                 message:"Explain how our invoicing API works, 60s, friendly tone" }
◀ { turn_id:"…", status:"running" }

# poll every 2–5s until status leaves "running"
▶ get_session { session_id:"9ab6…0514" }
◀ { status:"running", current_stage:"research", last_heartbeat_at:"…Z",
    credits:{budget:500,held:500,spent:0}, failure_reason:null }
   … (keep polling) …
▶ get_session { session_id:"9ab6…0514" }
◀ { status:"awaiting_gate", failure_reason:null,
    pending_gate:{ gate_id:"g1", stage:"script", options:["approve","revise"],
      question:"Approve this script, or tell me what to change?",
      artifact_names:["script"], artifact_excerpt:"SCENE 1 — …first ~2KB… …[truncated]" } }

# excerpt is enough to decide → approve (resumes the session)
▶ resolve_gate { session_id:"9ab6…0514", gate_id:"g1", decision:"approve" }
◀ { turn_id:"…", status:"running" }

# keep polling; the render turn runs, then:
▶ get_session { session_id:"9ab6…0514" }
◀ { status:"closed", credits:{budget:500,held:0,spent:466},
    video_url:"https://…/v1/videos/share/AbC…xyz", failure_reason:null }

▶ get_video { session_id:"9ab6…0514" }
◀ { session_id:"9ab6…0514", status:"closed", ready:true,
    video_url:"https://…/v1/videos/share/AbC…xyz" }

▶ close_session { session_id:"9ab6…0514" }   # releases the (already-zero) unspent hold
```

The `video_url` is an absolute, stable, unguessable, `noindex` share link that streams the MP4
through the gateway — hand it straight to the user; no API key is needed to play it.
