Martini CLI

Every tool the Martini MCP server exposes, as a command. For agents with a shell and for scripts: one HTTPS request per call, a static key, no session.

Set this up with a coding agent

Paste this into Claude Code, Codex, Cursor, or another agent working in a shell. It covers install, sign in, context, every command family, the output and exit contract, waiting, files, and an acceptance checklist.

Overview

The martini command reaches the same tools as the MCP connector, with the same names and the same arguments. What differs is the transport: a tool call is one HTTPS request with a key, so a shell script or a coding agent can use Martini without holding an MCP session open, and can pipe results, submit JSON it computed, and block until work settles.

A shell means the CLI: an agent that has one uses martini for every Martini task, even with the MCP connector installed. The MCP connector is for hosts without a shell — Claude Desktop, ChatGPT, claude.ai. Both reach the same tools with the same names and arguments.
npm install --global @martini-film/cli martini login martini guide martini use "Pilot" # this directory now works in that project martini get_board_overview --fields project,canvases martini upload ./ref.png ./take.mp4 martini create_node_and_generate --prompt "…" --model-id <model> --wait martini inspect_video --video-asset-ids <id> <id> --out-dir review martini files pull ./memory && martini files push ./memory

Install

npm install --global @martini-film/cli # puts martini on PATH npx @martini-film/cli <command> # or run one command without installing

Requires Node.js 20.10 or later; the package has no runtime dependencies. For environments without Node, a single-file Linux binary is attached to each release.martini version prints the version, which tool list commands run with (the one the build carries, or the server's cached one) with both hashes, and the config file paths.

Sign in

martini login

$ martini login Open https://www.martini.film/cli/auth and enter the code 7KQ4-M2XR Waiting for approval…

Open the URL, enter the code, approve. As you (recommended) gives the CLI a key that acts in every organization you belong to — one login for all your work — saved as the profile account in ~/.config/martini/config.json. One organization only saves a profile named after it (acme-films); pick it for a shared machine, or when the team should see the key in its own Settings. The first profile becomes the default; switch with --profile <name> or MARTINI_PROFILE. Any member can sign the CLI in — the same people who can connect the MCP.

Either way, tools run as you: the project you name on each command decides what you can touch and whose olives pay, exactly as on the canvas. An organization key additionally reaches that organization’s REST API (Generation, Workflows), which an account key cannot (ORGANIZATION_REQUIRED). Signing in as you needs CLI 0.3.0 or newer; the approval page offers only what the CLI that asked can collect.

A started pairing is remembered for ten minutes, so martini login run again resumes it instead of starting over; one that nobody approved and that has under two minutes left is replaced by a fresh code. An agent whose shell step cannot block on a person runs martini login --no-wait, relays the code and how long it lasts (expiresInSeconds), and runs martini login again once it is approved.

{ "default": "account", "profiles": { "account": { "apiKey": "mtn_cli_…", "apiUrl": "https://api.martini.film", "scope": "user", "expiresAt": "2026-12-09T13:00:00.000Z" }, "acme-films": { "apiKey": "mtn_cli_…", "apiUrl": "https://api.martini.film", "scope": "organization", "organizationId": "…", "expiresAt": "2026-12-09T13:00:00.000Z" } } }

The key appears under Settings → Martini API with its device label and expiry, and is revoked there — an account key under your personal Settings, an organization key under that organization’s. martini logout forgets the profile; martini whoami says which key the CLI acts as (its scope, organization, and your memberships) and where the config came from.

Two kinds of key

KeyMinted byLivesFor
mtn_cli_…martini login90 daysOne per device: as you (every organization you belong to) or for one organization. Labelled with the device in Settings → Martini API.
mtn_live_…Settings → Martini APIUntil revokedThe same key the Generation and Workflows APIs take. For scripts and servers.

A raw Martini API key always works: MARTINI_API_KEY=mtn_live_… or --api-key. The CLI never needs the generation-API grant that the Generation and Workflows APIs require — the tool surface is open to every account, as the MCP is.

Where credentials come from

Most specific first:

  1. Flags: --api-key, --api-url, --profile.
  2. Environment: MARTINI_API_KEY, MARTINI_API_URL, MARTINI_PROFILE, MARTINI_CONFIG_FILE.
  3. The nearest .martini/config.json above the working directory. A file there without a key adds its project to the profile below instead of hiding it.
  4. ~/.config/martini/config.json — the profiles martini login saves.

Project and canvas context

Most tools take a projectId, many a canvasId, the workflow tools a runId. Choose the project once with martini use, by name or id, and stop repeating it:

martini use "Pilot" # or: martini use <project-uuid> --canvas "Storyboard" martini get_board_assets --fields assets.id,assets.status martini get_asset --asset-id <id>

martini use checks the project with one read and writes .martini/context.json in the working directory; every later command run in that directory tree takes its project and canvas from it. An agent's shell is new on every command, so an exported variable does not last from one step to the next, and a file beside the work does. With one canvas in the project that canvas is chosen; with several, name one with --canvas. The file holds no credential and is ignored under another --api-url; martini use --clear removes it and martini whoami shows what is in effect.

When a tool takes one of the three ids and you did not pass it, the CLI fills it from --project / --canvas / --run, else MARTINI_PROJECT_ID / MARTINI_CANVAS_ID / MARTINI_RUN_ID, else what martini use set, else the config file. A flag or variable that names another project replaces the directory's context whole, so its canvas is never sent with a different project.

Commands

Every tool is a command

martini <tool> exists for every entry of the tool manifest (GET /v1/tools, the same tools the MCP connector exposes). Flags are derived from the tool's arguments: assetId is --asset-id, arrays take the values after the flag, a repeated flag, or a JSON list, objects take JSON, booleans stand alone. A list of objects also reads a file (--nodes @nodes.json) or takes one JSON object per value (--nodes '{…}' '{…}'), and --args '{…}' or --args @args.json (alias --json) passes the whole arguments object instead — the way to submit a plan a script computed without retyping it. An argument that names an asset takes either spelling a result prints: the bare id, or the node id asset-<id>.

Where a node, a draft, or a generation takes a media URL (--reference-images, --start-frame-url, --end-frame-url, --image-url), an asset id from the project works too: the server uses that asset's URL, so an id from an upload or a generation goes straight into the next shot. An asset whose media another node also shows — a duplicate and its original — is refused (INVALID_MEDIA_REF), because no URL can say which of the two you meant; the error names the nodes and gives the URL to pass instead.

martini --help # every command on one screen, tools grouped by noun martini create_nodes --help # the tool's own description, every flag with type, required, and keys, an example martini create_nodes --schema nodes --keys # the keys of one JSON argument, a line each martini create_nodes --schema nodes # that argument's full JSON Schema martini create_nodes --nodes @nodes.json --fields nodes.nodeId martini set_asset_metadata --json '{"displayNames": [{"assetId": "<id>", "displayName": "Hero close-up"}]}'

Tool descriptions, limits, and required arguments are the manifest's, so --help says what the MCP says. One default differs: create_node is sent --verbosity summary unless you pass a verbosity. Unknown flags and missing required flags are refused before anything is sent (exit 2). A tool your account cannot use answers TOOL_NOT_FOUND, as it is absent from your MCP connector too.

Under each JSON flag, --help names the keys it takes on one line — shape: { x*, y*, width, height } for an object (* marks a required key), each: { … } for a list of objects, one of: for a union — and a Rules: block spells out the tool's own argument rules (Exactly one of: --video-asset-id | --video-asset-ids). martini <tool> --schema <flag> prints that argument's full JSON Schema (--schema alone prints the whole input schema), and --schema <flag> --keys lists its keys one per line; a dotted name steps inside one argument (--schema nodes.placement, a key of each element; --schema edit.assemble, the variant whose action is assemble). Both work offline and without a key. The example is built from the schema and runs as written, so create_nodes --help shows --nodes '[{"nodeType":"video"}]' rather than a placeholder. Help is sized to a first call: a tool with many flags lists its optional ones on a line each and says so at the end, and --help --all prints the whole page.

The list comes from the server, not only from the CLI's build. The CLI caches the server's list for your key (under ~/.cache/martini/) and refreshes it — one conditional GET /v1/tools — when a command or flag is unknown, then tries again, so a tool added after your CLI was built is a command without an update. The refresh never runs on an ordinary command, and MARTINI_MANIFEST_REFRESH=0 turns it off. martini tools says what the server has that this build lacks; martini call <tool> --args '{…}' reaches a tool no list shows, with exactly the arguments given.

Compound commands

What no single tool does:

CommandDoes
login, logout, whoamiPair this device through the browser and save a 90-day key; forget a saved profile; show which organization and key the CLI acts as, in which project, and where the config came from.
use [<project-id|name>] [--canvas <id|name>]Choose the project and canvas for this directory: writes .martini/context.json, which every later command there reads. Alone it prints what is set; --clear forgets it.
tools, modelsHow the server’s tool list differs from this build’s (serverOnly, buildOnly, changed; --list adds every tool, --refresh refetches); the model catalog, cached locally for an hour (--refresh, --query, --capability).
call <tool> --args <json|@file>A tool call by name with exactly the arguments given — no flags, no local checks, no context beyond --project / --canvas / --run typed on it, or --context, which adds the project in effect here. For a tool no tool list shows.
upload <path…>Upload local files as canvas assets — prepare, PUT, complete in one step, then wait for processing. --canvas, --bin, --content-type.
download <asset-id…>Save completed assets to files: --out for one, --dir for many.
wait <asset-id…> | --job | --run | --renderBlock until assets, a job, a run, or a render settle; exit 4 with the pending ids on timeout.
files ls | cat | write | pull | pushProject memory files, one at a time or as a directory. push lists the files it wrote, the ones it left unchanged, and the ones newer on the server than here (behind, with a FILES_BEHIND warning); it refuses a file edited server-side since the pull (PROJECT_FILE_CHANGED) and exits 1. pull keeps a file edited here and sets a conflicting server copy aside; --force overwrites.
probe <path|asset-id>…Duration, resolution, fps, codecs, channels, and sha256 of local files or assets, in one JSON report that names each file’s kind (video, audio, image). A still has no fps, and embedded cover art is not counted as a video stream (needs ffprobe).
guide [topic] [--list] [--refresh]The operator rules an MCP agent receives when it connects, worded for the CLI and split by topic: orientation, library, timeline, generation, storage, support, shell (--list gives each topic’s size). Cached beside the tool list and refreshed on each run; offline it prints the copy the build carries. A GUIDE_SOURCE notice on stderr says which copy was printed.
init [--write] [--force] [--dir <repo>]Print, or with --write install, the Martini agent skill (.agents/skills/martini/SKILL.md and .claude/skills/martini/SKILL.md) and a three-line pointer in AGENTS.md, and adds the CLI’s local state (.martini/files.json, .martini/conflicts/) to .gitignore. An edited skill file is kept unless --force; nothing outside the repository is written.
versionThe CLI version, which tool list commands run with (this build’s or the cached server list, with both hashes), and the config file paths.

Global flags on every command: --fields, --out, --out-dir, --raw, --args (alias --json), --no-context, --wait / --no-wait, --timeout, --project / --canvas / --run, --api-key / --api-url / --profile, --quiet. A tool never sees them as arguments.

Output and exit codes

Exit 0 means the command did everything it was asked. On any other code, read the error on stderr; a command that did part of its work prints that part on stdout too — a timed-out wait's pending ids, a push's skipped files, the quote a cap refused.

Results are JSON on stdout — compact when piped, indented on a terminal; the text commands (guide, init without --write, files cat, --help) print text. Warnings are JSON on stderr and change neither stdout nor the exit code. --quiet prints nothing on success.

--fields a,b.c keeps only those dotted paths; arrays are traversed element-wise (nodes.id), a map keyed by id is stepped into for you (assets.status), and martini <tool> --help lists the result fields of tools that declare them, as paths. A path that matches nothing beside ones that do is named in a warning. When no path matches, a read exits 2 (NO_FIELDS_MATCHED, with the nearest paths the result or the tool's result schema has) so you fix the paths and read again, and a command that wrote something prints its whole result with that warning, so it is never run twice for a typo.

Nothing is shown to an agent until it opens a file, so looking at a result is two steps: save, then open. --out <path> writes the result to a file and prints a one-line JSON receipt. With a media extension (.jpg, .png, .webp) it writes the media bytes of tools such as view_asset or render_timeline_frame, and the receipt carries the tool's metadata; --raw asks for the bytes whatever the file is called. Only the path the flag names is written, and the receipt names it whole, resolved from the working directory. A name whose extension contradicts the bytes (still.png for WebP bytes) would make a reader that trusts the extension refuse the file: from a read, nothing is written and the command exits 2 naming the right extension, since running it again costs nothing; from a tool that already did its work, the file is written as named with an EXTENSION_MISMATCH warning. JSON is never saved under an image name. Printed to stdout, a media block is a stub (mime type, byte size, a hint), never base64.

--out-dir <dir> (read-only tools) writes every media block to its own file, in the order the tool wrote them, and prints { dir, files: [{ path, mimeType, bytes, about }], notes? }. A file is named by the asset the text block beside it names: <i>-<id>.jpg from a batch's Asset <id> (i/n) header, <id>.png from a JSON text block's assetId, else block-<n>; about is that text block parsed — asset id, shot number, duration — so which file is which needs no second call. A batch item that failed writes no file and appears under notes. Without the flag, inspect_video prints its ordered blocks, each asset's text before the contact sheet it describes, a JSON text parsed, the sheets as stubs, and a MEDIA_NOT_PRINTED note on stderr.

{ "blocks": [ { "type": "text", "label": "Asset 5b0c… (1/2)", "json": { "assetId": "5b0c…", "frameCount": 12 } }, { "type": "image", "mimeType": "image/jpeg", "byteLength": 48211 }, { "type": "text", "text": "Asset 9e41…: ASSET_NOT_READY: Asset \"9e41…\" has status \"pending\". …" } ] }

Errors are one JSON object on stderr, never mixed into stdout:

{ "error": { "code": "ASSET_NOT_READY", "message": "Asset 5b0c… is still processing.", "hint": "Run martini wait 5b0c… to block until it settles.", "tool": "get_asset_download_url", "exitCode": 4 } }

code is the tool's own MCP error code (ASSET_NOT_READY, NODE_NOT_FOUND, INSUFFICIENT_CREDITS, …), or USAGE for a flag problem. Branch on code and the exit code; the message may change.

ExitMeaning
0Done: the command did everything it was asked.
1The tool failed, part of the work did not land (a push that skipped a file, a pull with a conflict), or what was waited for failed (ASSET_FAILED, JOB_FAILED). The error object carries the code; what was done is on stdout.
2Usage: unknown command, missing or bad flags (nothing was sent), or --fields on a read that matched nothing.
3No key, or the key is invalid, revoked, or expired (API_KEY_EXPIRED after 90 days for a login key).
4Not ready, or a wait timed out. The result the command produced is still printed, with the pending ids, and the error names the martini wait command that resumes it.
5Over budget: not enough olives, or a quote over --max-olives. Nothing was started.

Waiting

Generation and rendering are asynchronous. --wait blocks until the produced assets (or the render) settle and adds waited to the result — one shape whatever was waited on: { kind, status, assets? | job? | run? | delivery? }, where status is the terminal status and the settled records sit under the key that names them (a settled asset carries its media url); martini wait prints the same object. Exit 0 means what was waited for completed: a shot that settles on failed or cancelled prints the same object and exits 1 (ASSET_FAILED, JOB_FAILED). It is on by default for single-item generation and rendering tools — create_node_and_generate, generate, render_timeline, enhance_draft, upscale_assets, render_blender_take — and off for batch tools, where you fan out and then wait once. --no-wait turns it off. --timeout <seconds> (default MARTINI_WAIT_TIMEOUT_SECONDS when set, else 50) ends a wait with exit 4: the result the command produced is still printed on stdout, with pending (and settled) ids, and the error names the martini wait that resumes it, project included, so it runs as written in a new shell. Size --timeout to what your shell step allows — a video generation takes minutes.

martini create_node_and_generate --prompt "…" --model-id <model> --fields waited --timeout 290 martini create_nodes_and_generate --json @shots.json --fields nodes.nodeId > nodes.json martini wait $(jq -r '.nodes[].nodeId' nodes.json) --timeout 300 martini wait --run <run-id> --timeout 600 # a workflow run; get_run long-polls on the server martini wait --render <render-id> # a timeline render

Material and memory files

martini upload ./ref.png ./take.mp4 # prepare → PUT → complete, then wait for processing martini download <asset-id> --out ./take.mp4 # one file martini download <id> <id> --dir ./out # many martini probe ./take.mp4 <asset-id> # duration, resolution, fps, codecs, channels, sha256

upload is the three-call upload (upload_assets_prepare, the PUT to storage, upload_assets_complete) in one step, from files on disk, so nothing rides through the agent's context as base64. download follows the presigned URL and writes the bytes. probe needs ffprobe on PATH.

martini files ls martini files cat notes/continuity.md martini files write notes/continuity.md @continuity.md martini files pull ./memory # every memory file to a directory, with the versions you saw $EDITOR ./memory/notes/continuity.md martini files push ./memory # writes what changed; exits 1 if a file was edited server-side since the pull

Project memory files are the notes and instructions a project keeps for its agents (read_project_file, write_project_file). files pull and files push edit them as a directory, and neither loses an edit silently. push compares each file's server version with the one pulled and answers PROJECT_FILE_CHANGED for a file changed since: the file is listed under skipped and the push exits 1. pull never overwrites a file edited here; when the server's copy changed too it is written to .martini/conflicts/ and the pull exits 1 (PULL_CONFLICT) — merge, delete the copy, push. --force overwrites, in either direction.

Agents

martini guide prints the operator rules an MCP agent receives when it connects — orienting on a board, the library, the timeline, generating and reviewing shots, uploads and downloads, support — worded for the CLI, with a topic on working from the shell. martini guide <topic> prints one topic, --list names them with their sizes. The CLI asks the server for its current guide on each run (GET /v1/guide, conditional) and keeps it beside the cached tool list, so a rule changed after your build still reaches the agent; offline it prints the copy the build carries. Every answer carries the hash of the current guide, so a command run with a stale copy prints one GUIDE_CHANGED warning that says to read it again.

martini init --write sets a repository up for agents: a short skill at .agents/skills/martini/SKILL.md (Codex, Cursor, and other agents) and .claude/skills/martini/SKILL.md (Claude Code; one file when one directory links to the other), plus a three-line pointer in AGENTS.md. The skill routes the agent to the CLI and sends it to martini guide and martini <tool> --help for the detail, so a session pays for neither until it needs them. Without --write it prints both and writes nothing; an edited skill file is kept unless --force; --dir names another repository. The same rule reaches agents already connected over MCP: the connector's server instructions state it, and get_board_overview repeats it to callers it recognizes as a terminal or IDE agent.

martini guide # the operator rules, every topic martini guide generation # one topic: orientation, library, timeline, generation, storage, support, shell martini init # print the skill and the AGENTS.md pointer martini init --write # install them in this repository (idempotent; --force replaces an edited skill)

Every call names the CLI and its version in X-Martini-Client, and the User-Agent carries the host agent it runs inside when the host's environment says (Claude Code, Cursor, Codex, Gemini CLI, opencode, Windsurf), so a call from the CLI under Claude Code is distinguishable from one Claude Code made over MCP. MARTINI_HOST_AGENT names the host explicitly; nothing is guessed. In Martini the call shows up as the CLI in the collaborator strip and in analytics.

Generations cost olives and cannot be undone. An agent should lay out the plan — model, prompts, count, rough cost — and get the user's approval before a batch, exactly as over MCP. The tools that quote first (generate_nodes, upscale_assets, enhance_draft) start nothing until called again with the quote's planHash and an approval; --approve --max-olives <n> does both in one command and refuses, with nothing started and exit 5, when the estimated total is over the cap — the one refusal to take back to the person.

martini generate_nodes --node-ids <id> <id> --approve --max-olives 20 --wait

The HTTP surface underneath

The CLI is a client of two routes any language can call with a Martini API key: GET /v1/tools, the manifest (every tool with its JSON Schema, annotations, and profile; the response ETag is its hash), and POST /v1/tools/{name}, which runs one tool with the JSON body as its arguments and answers the result with the MCP envelope removed. ?select=a,b.c is the server-side projection behind --fields; ?format=raw answers a single media result as its bytes; ?format=blocks answers the tool's content blocks in order (&media=stub for sizes instead of bytes), behind --out-dir and inspect_video.

curl https://api.martini.film/v1/tools \ -H "Authorization: Bearer $MARTINI_API_KEY" curl -X POST "https://api.martini.film/v1/tools/get_asset?select=asset.id,asset.status,asset.imageUrl" \ -H "Authorization: Bearer $MARTINI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "projectId": "…", "assetId": "…" }'

Errors are { "error": "<copy>", "code": "<STABLE_CODE>", "tool": "<name>" } with the tool's own code where it has one; a name that is not in the manifest answers TOOL_NOT_FOUND. @martini-film/client exposes the pair as martini.tools.list() and martini.tools.call(name, args, { select }); martini-client (Python) as martini.tools.list() and martini.tools.call(name, arguments, select=…). The full /v1 surface is described at api.martini.film/v1/openapi.json.

Not yet available

  • A local mirror of a canvas as a file tree (martini mirror pull). Read with get_board_overview and --fields for now.
  • Compiled binaries for macOS and Windows. The npm package runs wherever Node.js 20.10 or later does; the single-file binary is Linux x64.
  • Streaming progress while waiting. --wait returns once, with the settled result.

© 2026 C47 Inc.