SKILL.md — installing and operating clipwright-mcp
A self-contained guide for an AGENT (or a human in Claude Code) that needs no
spoken help from a developer. Follow the steps in order. Not to be confused with
apps/web/.claude/skills/* — those are dashboard skills for the Next.js app and
have nothing to do with the MCP server.
The package @clipwright/mcp-server (bin: clipwright-mcp) is published to npm
under the MIT license, so you do NOT need the repository to install it — npx is
enough. Building from source is kept below as the development path.
What changed in each version — above all what was removed or changed
incompatibly — is in CHANGELOG.md, shipped next to this file. Read its
Breaking: entries before you move to a newer version.
Four client-side packages — @clipwright/core, @clipwright/sdk,
@clipwright/mcp-server and @clipwright/cli — are MIT; the service itself
(REST API, render pipeline, dashboard) is closed. That split is deliberate, not
an oversight: see LICENSE in the repository root.
1. Installation
There are two ways to connect, and both serve the same tools:
- the hosted server at
https://mcp.clipwright.io/mcp(Streamable HTTP), signed in with OAuth or with acw_*key — section 1.4. Nothing to install; - this package run locally over stdio with
CLIPWRIGHT_API_KEY— sections 1.1 to 1.3, and section 2.
1.1 From npm — the normal path
npx -y -p @clipwright/mcp-server@latest clipwright-mcp
Expected: the process does NOT exit, prints clipwright mcp server running on stdio to stderr, and hangs waiting for JSON-RPC. That is what a healthy server
looks like, not a freeze. Stop it with Ctrl+C.
Exiting immediately with CLIPWRIGHT_API_KEY env var is required is also
expected when the key is empty (src/index.ts) — it is not a broken build.
Go straight to section 1.3: the host config for this variant differs only in the
command (npx instead of node with an absolute path); both are shown there.
1.2 From source — development only
Requirements: Node ≥24, pnpm (packageManager: pnpm@11.13.1 in the root
package.json — with corepack available: corepack enable).
git clone <repo-url> clipwright # or use a checkout you already have
cd clipwright
pnpm install
pnpm build # pnpm -r build — builds every package in topological order
If you only need this surface (faster, but @clipwright/core and
@clipwright/sdk must be built first — ... pulls them in automatically):
pnpm --filter @clipwright/mcp-server... build
After the build, packages/mcp-server/dist/index.js must exist. Check it:
ls packages/mcp-server/dist/index.js
Pre-flight check of the built binary
This applies to source builds. Before wiring the server into a host, make sure it starts at all and does not die on imports:
CLIPWRIGHT_API_KEY=cw_test node packages/mcp-server/dist/index.js
Expected: the process does NOT exit on its own, prints clipwright mcp server running on stdio to stderr, and waits on stdio for JSON-RPC. Stop it with
Ctrl+C. If it instead exits immediately with CLIPWRIGHT_API_KEY env var is required, the variable was not passed through (expected behaviour on an empty
key, see src/index.ts) — not a bug. If it dies on a module import, the build is
incomplete: go back to step 1.2 and run pnpm build from the root rather than a
targeted --filter, so @clipwright/core and @clipwright/sdk are definitely
rebuilt.
1.3 Connecting it to an MCP host
Variants A and B below run the server FROM SOURCE and therefore need the ABSOLUTE
path to dist/index.js:
realpath packages/mcp-server/dist/index.js
If you installed from npm (section 1.1), you do not need a path — take variant C
at the end of this section; it differs only in command/args.
Variant A — .mcp.json (project-level host config):
{
"mcpServers": {
"clipwright": {
"type": "stdio",
"command": "node",
"args": ["/absolute/path/to/clipwright/packages/mcp-server/dist/index.js"],
"env": {
"CLIPWRIGHT_API_KEY": "cw_...",
"CLIPWRIGHT_API_URL": "https://api.clipwright.io",
"CLIPWRIGHT_CLIENT_ID": "<see section 2 — generate BEFORE the first run>"
}
}
}
}
Variant B — claude mcp add (variant A as a single command):
claude mcp add clipwright \
-e CLIPWRIGHT_API_KEY=cw_... \
-e CLIPWRIGHT_API_URL=https://api.clipwright.io \
-e CLIPWRIGHT_CLIENT_ID="$(node -e "console.log(crypto.randomUUID().replaceAll('-',''))")" \
-- node /absolute/path/to/clipwright/packages/mcp-server/dist/index.js
Variant C — from npm, without the repository. Same thing, but the command is
npx:
{
"mcpServers": {
"clipwright": {
"type": "stdio",
"command": "npx",
"args": ["-y", "-p", "@clipwright/mcp-server@latest", "clipwright-mcp"],
"env": {
"CLIPWRIGHT_API_KEY": "cw_...",
"CLIPWRIGHT_API_URL": "https://api.clipwright.io",
"CLIPWRIGHT_CLIENT_ID": "<see section 2 — generate BEFORE the first run>"
}
}
}
}
As a single command:
claude mcp add clipwright \
-e CLIPWRIGHT_API_KEY=cw_... \
-e CLIPWRIGHT_API_URL=https://api.clipwright.io \
-e CLIPWRIGHT_CLIENT_ID="$(node -e "console.log(crypto.randomUUID().replaceAll('-',''))")" \
-- npx -y -p @clipwright/mcp-server@latest clipwright-mcp
@latest here is a deliberate choice, not sloppiness. It pulls a fresh
version on every start: right for a prototype, because the contract still moves
and a client out of sync with the server costs more than an unexpected upgrade.
Once the contract settles, the version should be pinned.
Environment variables:
| Variable | Required | Meaning |
|---|---|---|
CLIPWRIGHT_API_KEY |
YES | Bearer token shaped cw_*. Without it the process exits immediately with an error (src/index.ts). The token is ISSUED TO YOU PERSONALLY: it lives in the api_tokens table, is bound to an account, and is revoked independently of anyone else's. Do not forward it — a second person gets their own. What separates people is the ACCOUNT, not the token: accounts cannot see each other's runs, each has its own daily cap and its own idempotency axis, and several tokens on one account share all of it. |
CLIPWRIGHT_API_URL |
no | Production URL. Left unset, the SDK supplies https://api.clipwright.io itself. Set it explicitly only when you target another environment. |
CLIPWRIGHT_CLIENT_ID |
YES (see section 2) | Unique identifier of this INSTALLATION. |
After connecting, restart or reopen the MCP host and confirm that tools/list
returns 31 tools: make_ugc, get_run, get_run_shots, quote_revision, revise_run, quote_candidate, create_candidate,
list_generation_models, quote_image, generate_image, quote_video, generate_video, quote_ugc, list_actors,
list_voices, upload_image, get_account, create_actor, quote_actor, delete_actor, get_actor_defaults,
set_actor_defaults, quote_faceless, make_faceless, list_workflows, get_workflow, prepare_workflow_plan,
get_workflow_plan, approve_workflow_plan, quote_workflow_plan, run_workflow_plan.
Workflow calls are explicit steps: list/read a method, prepare its complete
document, read all readiness issues and warnings, approve the chosen revision,
then quote and show its native price before a paid launch. Preparation, approval
and quoting start no run. To save, pass both plan_id and base_version with
the document; omit both to create. Unknown write fields are refused.
run_workflow_plan requires the quote's revision, content digest, semantic input
digest and quote id, plus an explicit idempotency key. It returns the captured
receipt and native run id at once. Poll get_run; its optional workflow field
keeps the purchased revision and available outcome evidence. Technical evidence
does not mean a person accepted the creative result. After an uncertain paid
answer, preserve the original request and key; a new key can pay twice.
1.4 The hosted server — OAuth or a cw_* key
The hosted server runs the same tools at https://mcp.clipwright.io/mcp. It
keeps no session: every message is its own POST. Add the URL to the client;
on first use the client opens a browser, the account holder signs in to
Clipwright and approves the app.
Status: Claude Code and the device-code flow were verified live on 2026-10-18; the other commands come from each client's own documentation:
| Client | How to add | Verified |
|---|---|---|
| Claude Code | claude mcp add --transport http clipwright https://mcp.clipwright.io/mcp, then /mcp in a session to sign in |
verified live 2026-10-18 |
| Claude.ai, Claude Desktop | Customize → Connectors → Add custom connector, paste the URL | not yet verified live |
| ChatGPT | the URL as the remote MCP server of a plugin (OpenAI's guide "Connect and test your plugin") | not yet verified live |
| Cursor | ~/.cursor/mcp.json: {"mcpServers": {"clipwright": {"url": "https://mcp.clipwright.io/mcp"}}} |
not yet verified live |
| VS Code | .mcp.json at the workspace root: {"mcpServers": {"clipwright": {"type": "http", "url": "https://mcp.clipwright.io/mcp"}}} |
not yet verified live |
| Gemini CLI | gemini mcp add --transport http clipwright https://mcp.clipwright.io/mcp, then /mcp auth clipwright |
not yet verified live |
| Codex | codex mcp add clipwright --url https://mcp.clipwright.io/mcp, then codex mcp login clipwright |
not yet verified live |
Permissions. The approval page lists what the app asks for:
mcp:read— the tools withreadOnlyHint: true: account, runs, shots, voices, actors, workflows and every quote. Free.mcp:write— every other tool:make_ugc,make_faceless,revise_run,create_candidate,generate_image,generate_video,create_actorandrun_workflow_planspend credits;upload_image,delete_actor,set_actor_defaults,prepare_workflow_planandapprove_workflow_planchange the account.
The account holder approves or declines the whole request; only an admitted
account can approve. A connection without mcp:write that calls one of those
tools gets HTTP 403 with error: "insufficient_scope" and
WWW-Authenticate: Bearer error="insufficient_scope", scope="mcp:write", resource_metadata="…".
Nothing is charged and no run is created. Reconnect the app and approve
spending to fix it.
Revoking. The account holder revokes an app at
https://app.clipwright.io/api-keys, under Connected apps. Its next request
gets 401. A connection lasts 90 days from approval and is not extended by
use: after that the app must sign in again. An access token lasts one hour;
clients renew it themselves.
Files. The hosted server cannot read files on the agent's machine, so
upload_image refuses a path there. Pass a public https URL of the PNG or
JPEG as image to quote_ugc and make_ugc instead (#709 tracks upload by
URL).
Idempotency. Section 2 does not apply here. Each connected app (one
grant) is its own client identity, and so is each cw_* key, so two
connections of one account never collapse into one run. A repeated request
within one connection returns the same run, as on stdio.
An agent with no browser signs in with a device code (verified live
2026-10-18). Only the first-party client clipwright-agent may use this flow:
curl -s https://mcp.clipwright.io/device/auth -d client_id=clipwright-agent \
--data-urlencode "scope=mcp:read mcp:write offline_access" \
--data-urlencode "resource=https://mcp.clipwright.io/mcp"
- Show the person
user_codeandverification_uri(https://mcp.clipwright.io/device). They open the page on any device, enter the code, sign in and approve. The approval page repeats the code: they approve only if it matches. - Meanwhile poll
POST https://mcp.clipwright.io/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_code,device_codeandclient_id=clipwright-agent, no more often than every 5 seconds. Until approval the answer isauthorization_pending; a faster poll getsslow_down. - Send
access_tokenasAuthorization: Bearerto/mcp. Renew it withgrant_type=refresh_token, the refresh token and the sameclient_id. Each renewal returns a new refresh token: keep the newest one.
With a cw_* key instead of OAuth — for CI and scripts. A key holds both
permissions:
claude mcp add --transport http clipwright https://mcp.clipwright.io/mcp --header "Authorization: Bearer cw_..."
Errors of the endpoint itself: no token, or a revoked or unknown key —
401 with
WWW-Authenticate: Bearer resource_metadata="https://mcp.clipwright.io/.well-known/oauth-protected-resource/mcp", scope="mcp:read mcp:write",
which a client follows to sign in; an expired or revoked OAuth token — the same
401 with error="invalid_token" added; GET /mcp with a valid token — 405;
a body over 1 MB — 413.
2. CLIPWRIGHT_CLIENT_ID — required, and WHY
This section is about the local server. The hosted server takes the client identity from the connection itself (section 1.4).
Rule: EVERY installation must get its OWN unique CLIPWRIGHT_CLIENT_ID BEFORE
the first make_ugc call. Do not copy the value from another machine, and do
not leave it empty hoping the default will "work out somehow".
Why this is not optional
The idempotency key the SDK stamps on every paid POST /v1/skills/make_ugc/run
is derived deterministically from the hash of the input (script, person,
image, … after canonicalisation) and the client identity
(idempotencyKeyFor in packages/core/src/idempotency.ts, used by
packages/sdk/src/index.ts). The server deduplicates on that key: if a stored
key matches on request_hash, it returns 200 with the run created by the first
request that used the key, instead of starting a new paid render.
That is the right behaviour when the SAME user retries — you should not pay twice for an agent's accidental double click. Its boundaries are these.
Collapsing requires BOTH to match: the account and the clientId. Separating
either one is enough.
- The account separates by construction. Both the unique index
(
runs_account_idempotency_key_unique) and the lookup use the pair(account_id, idempotency_key). A run from ANOTHER account can never reach you, and nobody can take your key — whatever sits inCLIPWRIGHT_CLIENT_ID. The axis is the account, not the token. A separate token does not by itself mean a separate account: several live tokens on one account are normal (laptop, CI, smoke script), and they all share one idempotency axis. The account is not visible from the token — only the service side knows it, so "is this my own account?" is confirmed by whoever issued the token. clientIdseparates within one account. On its own it comes from~/.clipwright/client-idand is born random on first run, so two genuinely separate installations diverge even without the environment variable. It can only match if it was MADE to match: a sharedHOME, a value copied from someone else's machine, an image cloned together with the file.
The dangerous case is exactly the crossing of those two conditions: one account
AND one clientId. Then a request whose input matches an earlier one — for
example, both people copied a sample from this SKILL.md verbatim — returns 200, a
run_id and a playable link. But it is the video rendered for the first person,
presented to the second as their own success. A diagnostic tool would read as
"works" while no paid render ever ran for the second person.
Hence the rule: a second person gets their own ACCOUNT and a token issued on
it, and the value of CLIPWRIGHT_CLIENT_ID is not copied from anyone's machine.
Setting it explicitly is mandatory wherever the file resolver cannot give a stable
value of its own: a read-only or shared HOME, a container from a cloned image,
CI.
How to set it
An explicit CLIPWRIGHT_CLIENT_ID in the host environment (section 1.3, variants
A/B/C) is the most reliable path, because it does not depend on the state of the
file system. The value is any unique string; for consistency with the SDK's own
generator use the same shape (a UUID without dashes). Generate it with Node, not
uuidgen: Node is certainly present here, whereas uuidgen may be missing on a
bare Linux image — and then the shell silently substitutes an EMPTY string, which
switches off precisely the protection the variable exists for.
node -e "console.log(crypto.randomUUID().replaceAll('-',''))"
If the variable is NOT set, resolveClientId() (packages/sdk/src/client-id.ts)
falls back to the file ~/.clipwright/client-id: on first run it generates a
random id and stores it there, then reads it back. That is a workable fallback for
a single installation on a clean machine, but do NOT lean on it as your only
protection when an agent does the install: home directories carried along when
images, containers or dev environments are cloned can take
~/.clipwright/client-id with them and produce a collision this fallback will not
notice. When installing by this guide, always set CLIPWRIGHT_CLIENT_ID
explicitly in the host environment rather than relying on auto-generation.
If neither the variable nor writing the file is possible (read-only $HOME),
resolveClientId() deliberately THROWS instead of inventing a throwaway id on
every start — a silent fallback would disable deduplication entirely
(packages/sdk/src/client-id.ts).
The server does NOT die in that case, and this is verified by a test run. The
identity resolves lazily — only on the first make_ugc. In an environment where
~/.clipwright is not writable the server starts, tools/list returns every
tool, and quote_ugc, get_run and list_voices work: they do not need the
identity. Only make_ugc refuses, and its refusal names the variable that fixes
it. So the action to take is to set CLIPWRIGHT_CLIENT_ID explicitly — not to
hunt for a bug and not to reinstall the package.
3. CALL ORDER — quote_ugc first, make_ugc second
This is not a politeness convention but a mechanism, without which the contract stays silent about its own decisions.
quote_ugc → read source / resolved_aspect_ratio / warnings → make_ugc → get_run (polling)
What quote_ugc gives you that nothing else does. It is free, reserves
nothing, and answers the questions that would otherwise surface only after money
is spent:
| Response field | What it tells you |
|---|---|
credits_estimate |
the price — show it to the user BEFORE generating |
source |
dimensions of the selected actor variant, a probed image, or the default actor (432×768) |
actor |
selected actor_id, version and gender; default {gender} when no face is selected; null for caller-supplied image |
resolved_aspect_ratio |
the aspect ratio that will ACTUALLY be rendered for this input |
tts_model |
the speech model this run will use; the script's character ceiling depends on it |
warnings[] |
which of the parameters you passed will not be honoured |
contract_version |
which contract version this price was quoted under |
The verdict of quote_ugc and the verdict of make_ugc agree by
construction — both surfaces call the same function. If quote returns 400, the
run returns the same 400; if quote showed resolved_aspect_ratio: "9:16" for a
requested 1:1, then 9:16 is what gets rendered.
Unknown fields are refused, not ignored. A key the contract does not have —
aspectRatio, aspect, ttsModel — gets 400 unknown_field before any charge.
fields names each key, and the message suggests the matching field
(aspect_ratio). Until clipwright#166 such keys were dropped silently, so a
misspelled aspect_ratio rendered the default 9:16.
Rejected fields — do not try to pass them. They are not declared in the tool
schema, and the server answers 400 rejected_field before a run is even created:
| Field | Use instead |
|---|---|
character |
actor_id from list_actors, an image, or nothing (default actor); person does not select a face |
webhook_url |
poll get_run — webhooks are not delivered at all |
segments, broll_url |
a single script; segmented clips are not available yet |
disclosure_overlay |
nothing: no video carries a visible label, and the AI disclosure is in ai_disclosure of the finished run |
image and actor_gender with the killswitch off |
omit both — the render runs on the default actor |
The aspect ratio can be refused. Requesting aspect_ratio: "1:1" without an
image or actor_id returns a refusal: the default actor is vertical (432×768), and a square
frame could only come from the vendor's centre crop, which we do not do silently.
To get 1:1, select an actor with a verified square variant or pass an image
with a square public https source. An unavailable actor format is refused before payment.
4. The tools
Schemas come from the single source of truth @clipwright/core
(packages/core/src/skills.ts, barrel packages/core/src/index.ts); MCP does not
retype them by hand.
quote_ugc — free
The input is offeredUgcInputShape (packages/mcp-server/src/server.ts), that
is, the contract MINUS the rejected fields: they simply are not there, and listing
them here would mean offering what the server punishes you for (section 3).
script (required while segments is rejected; length is limited by the resolved
TTS model, as reported by tools/list: eleven_v3, eleven_v4 and gemini-3.8-flash-tts
allow 5000 characters, eleven_flash_v2_5 and eleven_turbo_v2_5 allow 10000. Spaces, audio tags and stress
marks count; emoji may count as two characters. There is no word-count limit or fixed
duration implied by it), tts_model (omit it and the voice decides: the Russian presets
owner_ru_clone and daria_ru_female and the catalog voices named ru_* speak
eleven_v4, a voice of kind model_voice speaks its own model gemini-3.8-flash-tts,
every other preset and catalog voice speaks eleven_v3; a raw voice_id
speaks eleven_v4 when at least 60% of the script's Cyrillic and Latin letters are
Cyrillic, and eleven_v3 otherwise; an explicit tts_model always wins;
eleven_flash_v2_5 and eleven_turbo_v2_5 cost less and suit
languages other than Russian), optionally person OR
image (a public https URL) with actor_gender (female/male, only next to
image), plus name, captions (defaults to false —
captions are opt-in, ASK THE USER FIRST), caption_style
(hormozi/tiktok/minimal), look (natural/commercial/raw_iphone),
aspect_ratio (9:16/1:1, with NO default — silence and an explicit choice
differ), resolution (720p/1080p/4k), voice/voice_id.
Russian stress marks. Tell a user who writes in Russian that they can fix stress:
write the stressed vowel as a capital inside a lowercase word — потОм, зАмок — and
Clipwright sends eleven_v4, eleven_v3 and gemini-3.8-flash-tts the stress mark U+0301
(пото́м). A capital that starts a
word stays a capital, a word with a second capital or an inner capital consonant
(ВУЗы, all caps) stays as written, and a U+0301 typed directly is kept. A single
inner capital vowel always reads as stress, so ЯндексЕда needs a space. Keep Russian
scripts on one of those three models: the 2.5 models misread the mark, so a script
carrying it draws a warning there, and so does any Cyrillic script. With a preset, a
catalog voice or voice_id the choice is eleven_v4 or eleven_v3, because
gemini-3.8-flash-tts speaks only its own voices. Retrying never
fixes stress; only the text does.
Break tags on eleven_v4 and gemini-3.8-flash-tts. These two models give no pause
for <break time="1.5s" />.
Clipwright cuts every tag of that form (time in s or ms) from the text it sends to
them, leaves it out of the character count, and names the cut in warnings[] of
the quote and the run. A script that holds nothing but break tags is refused before any
charge. The other models get the tag as written.
The exact list is always available from the tool schema itself: tools/list is
generated from the same shape, and fields that are not honoured carry
NOT HONORED YET right in their own description.
Spends no credits. The response is the raw quote in full:
{
"skill": "make_ugc",
"credits_estimate": 360,
"duration_estimate_sec": 12,
"warnings": []
}
warnings[] IS present here (passed through unfiltered) — read it: parameters
that will not be honoured (for example, captions ignored in this build) are
declared there rather than dropped silently.
Always call quote_ugc first and show the user the price before make_ugc
(the tool description says so in the imperative).
make_ugc — paid, creates a run
The input is the same shape plus attempt (an integer ≥1, optional; see section
5). Money: this is ONE paid render per call, unless idempotency deduplication kicks
in (section 2). The tool does NOT wait for the video — it starts the run and
returns a run_id immediately:
{
"status": "IN_PROGRESS",
"run_id": "run_...",
"state": "queued",
"video_url": null,
"warnings": [],
"next_action": "Video is NOT ready. Call get_run with run_id=run_... again in ~5 seconds. Repeat until state is 'succeeded' or 'failed'. Do NOT tell the user the video is done until you have a video_url."
}
The shape is the one get_run returns: make_ugc calls the same formatter, so
warnings[] is declared HERE, on the very first response, and not one poll later
— by then the paid run is already going. Read it: this is where a format snap or
an ignored field is announced.
After that you MUST poll get_run with that run_id every ~5–10 seconds until it
reaches a terminal state. Do not tell the user the video is ready until you have a
video_url.
Choosing a voice (voice / voice_id)
Two mutually exclusive optional fields in the make_ugc input:
voice— thenameof a voice from the freelist_voicestool: one of the presets (owner_ru_clone,sarah,george,eric,daria_ru_female), one of the thirty voices ofgemini-3.8-flash-tts(kindmodel_voice), or a catalog voice. The recommended path. Validated at the boundary: an unknown or retired name returns 400 immediately and no paid call is made. The two refusals differ: a retired voice's message names the date it left the catalog.- A
model_voicevoice belongs to its model. Naming one (kore,puck, …) makes the run speakgemini-3.8-flash-ttswith notts_model, and the quote names that model intts_model. A voice and atts_modelthat does not speak it return 400 before any charge, and the message names the way out: for amodel_voicevoice with aneleven_*model, omittts_model; for a preset, a catalog voice orvoice_idwithtts_model: "gemini-3.8-flash-tts", take a voice fromlist_voiceswithmodel: "gemini-3.8-flash-tts". That model returns no word timings, socaptions: trueis refused with it too. voice_id— a raw ElevenLabs id (20 characters), an escape hatch for voices OUTSIDE the catalog, cloned ones included. It is checked for free against the account at the start of the run (not duringquote): a non-existent id fails the run BEFORE any paid call.- The default voice follows the actor's gender. Without
voiceorvoice_id, a woman speakssarahand a man speaksgeorge. The gender comes from theactor_idcatalog entry, or fromactor_gendernext toimage. The default actor speaksgeorge. Withtts_model: "gemini-3.8-flash-tts"and no voice, a woman speakskoreand everyone elsecharon. An explicit voice always wins. - With
image, passactor_genderor avoice. Clipwright does not detect gender from the photo. Without either, the voice isgeorgeandwarnings[]says so.actor_gendernext toactor_id, or withoutimage, returns 400 before any charge. - Read
actorin the quote before rendering. It describes the selected actor (nullmeans the client sentimage). An explicit voice is used as requested whatever the actor's gender;actor_gendernext to it changes nothing, and a warning says so.
How to pick a voice when the user asks for one:
- Call
list_voiceswith the filters the user gave:language,gender,age,use_case. Language is a filter only: any voice speaks any supported language, so a Spanish script can use an English voice. - Play the
preview_urlof two or three candidates to the user. A catalog voice's sample is in its native language. Amodel_voicevoice's sample is in English; calllist_voiceswithlanguage: "ru"to get it in Russian. - Pass the chosen
nameasvoicetoquote_ugcand thenmake_ugc. - Next time, reuse that same
name. A name always means the same voice.
When the user sends a photo and asks for no particular voice, pass the gender of
the person in it as actor_gender. Ask the user only when you cannot tell.
For a Russian script, leave tts_model out or name eleven_v4 or eleven_v3 — both
read stress marks — and mark stress with a capital vowel (see Russian stress marks
above). A Russian voice speaks eleven_v4 on its own. A model_voice voice reads
stress marks too, on its own model gemini-3.8-flash-tts.
get_run — status polling
Input: { "run_id": "run_..." }. The response shape is implemented in
packages/mcp-server/src/format.ts (formatGetRun) and is given below as it
actually is.
A non-terminal run
(queued/scripting/tts/avatar/compositing/uploading) carries the stage
in state, with status always "IN_PROGRESS":
{
"status": "IN_PROGRESS",
"run_id": "run_...",
"state": "avatar",
"video_url": null,
"next_action": "Video is NOT ready. Call get_run with run_id=run_... again in ~5 seconds. Repeat until state is 'succeeded' or 'failed'. Do NOT tell the user the video is done until you have a video_url."
}
Terminal success carries BOTH status: "SUCCEEDED" AND state: "succeeded":
{
"status": "SUCCEEDED",
"run_id": "run_...",
"state": "succeeded",
"video_url": "https://...",
"duration_seconds": 13.08,
"ai_disclosure": "This video was generated with AI: ..."
}
ai_disclosure names what is synthetic in that video: a faceless video and a video with a presenter carry
different sentences. Pass it to the user as written when they ask how to label the publication.
Terminal failure carries BOTH status: "FAILED" AND state: "failed":
{
"status": "FAILED",
"run_id": "run_...",
"state": "failed",
"error": "<error text>"
}
warnings[] in get_run: every branch of the response (IN_PROGRESS,
SUCCEEDED, FAILED) carries the run's warnings — parameters that were not
honoured are always declared, never dropped silently. make_ugc shares the
formatter, so its first response carries them too. Read it on the terminal
response without fail: for instance, missing captions in the prototype are
announced exactly there.
An unknown terminal status on the server (say a hypothetical "canceled" that
is not in TERMINAL_STATES) is a deliberate degradation: isTerminal returns
false, so such a run lands in the IN_PROGRESS branch and the agent is told to
keep polling a run that has already finished. That is accepted prototype behaviour
— MCP and the API ship as one version — not a formatter bug.
get_run_shots — free, read-only
Takes a run_id and returns the shots of that video as the API states them
(GET /v1/runs/{id}/shots). It changes nothing and starts nothing.
planned: true— the answer holdsframe,timebase.fps,duration_frames, the narrationwordswith their timing, andtracks[]. Each shot has anid,start_frameandend_frame, akind, the words it covers, adescriptionand apreview_url. Spans are whole frames attimebase.fps.planned: false— a normal answer, not an error. The run has no shots to show, andeditable.statewitheditable.reasonsays why: the run did not succeed, itslineis one the API reads no shots for, or it kept no record of its media. Tell the user that reason; do not retry.
preview_url is a signed link to the shot's source picture or clip, which is a
large file. It expires: call the tool again for a fresh link and never store it.
A field named in withheld is null on every shot, so a null description or
preview_url there means "not shown", not "missing".
editable.media_available_until is how long the media of the run are stored.
paid_edits.enabled says whether a video can be made from an edit; while it is
false, none can, and revise_run answers editing_disabled. Read kind, line,
editable.state and each shot's operations as open strings, because a later
API may name values this text does not list.
quote_revision — free; revise_run — paid
Both take a run_id and exactly one of changes (shots to generate anew over
the run as it is: each a shot_id and its operation, such as
{ "mode": "retake" }) or document (the whole edited document; retimes,
deletions and uploads go this way). quote_revision answers what the revision
would change (summary) and cost (billing.total_credits) and spends nothing.
Show the user that price before revise_run.
revise_run starts a NEW run and returns it at once; poll it with get_run.
Once it succeeded it is editable under its own run_id. A revision that
delivers is charged its quoted total; one that fails is charged nothing, even
when its paid work was already done. An identical request returns the same run
it started before, so a call repeated after a lost answer starts nothing new;
pass attempt: 2 to start a second revision with the same request on purpose.
The refusals are terminal; do not retry them unchanged:
editing_disabled,editing_not_priced,editing_window_not_set(503) — paid edits are not open. Nothing was charged and no run was created.edit_ceiling_reached(409) —ceiling: "in_flight"means a revision of the original is still running: followrun_idswithget_runand start the next one after it finishes; if it is held for review, contact support."failed_or_unfinished"means support has to raise the limit.edit_invalid(422) — the document breaks a rule of the run;issueslists each, and the rules are in the run's shots.run_not_editable(409) withstate: "edit_window_closed"— the paid window of the original has closed.
quote_candidate — free; create_candidate — paid
A shot candidate is one picture or clip made for one shot of a make_faceless
run, to keep or discard in a later revision. Both tools take a run_id, a
shot_id and an operation the shot lists in get_run_shots (such as
{ "mode": "retake" }), and an optional take, 1 by default.
quote_candidate answers the unit, its credits and usable_until, the last
moment the candidate can enter a revision, and spends nothing. Show the user
that price before create_candidate.
create_candidate starts a NEW run of skill edit_shot and returns it at once;
poll it with get_run. Once it succeeded, candidate.preview_url shows what was
made. A delivered candidate is charged its one unit whether it is kept or
discarded; one that fails is charged nothing, even when its paid work was
already done. An identical request returns the same run it started before, so a
call repeated after a lost answer starts nothing new; pass take: 2 to make
another candidate with the same request on purpose, charged again.
The refusals are those of revise_run above. The candidate limits of an
original, running at once and failed or unfinished, are counted apart from its
revisions, and support raises only the revision limit. An operation the shot
does not offer is edit_invalid with the code action_not_allowed.
quote_image, quote_video — free; generate_image, generate_video — paid
One image or one silent clip from a prompt, each a run of its own.
list_generation_models lists the models, modes, aspect ratios, resolutions,
durations and unit_credits: credits per image, or per second of a clip. Any
other combination is unsupported_combination before any charge. The quotes
answer the credits, the width and height, and warnings; show the price first.
A reference is { "upload_id": ..., "rights_confirmed": true } for a picture
from upload_image, or { "run_id": ... } of a succeeded generate_image run
of this account until its reusable_until. In first_frame mode the frame must
match the requested aspect ratio within 2% (source_aspect_mismatch).
The run tools return the run at once; poll it with get_run, and once it
succeeded generation.url is the file. A delivered generation is charged its
quoted credits; one that fails is charged nothing, even when the vendor was
already paid. An identical request returns the same run; pass attempt: 2 for
another. While generations are closed the tools answer generations_disabled
or generations_marking_pending and charge nothing.
list_actors — free, no input
Returns the permanent actor catalog from /v1/actors, including stable
actor_id, version, name, gender, approximate age and verified image formats.
Each variant has dimensions, preview_url and preview_expires_at. Preview
links expire; retain actor_id for reuse and call the catalog again for fresh previews.
Pass actor_id to both quote_ugc and make_ugc. It cannot be combined with
image or person. An unavailable actor or format returns an error before paid
work; it never substitutes the default face. Existing runs retain the selected
version and image hash even if the catalog changes later.
list_voices — free, optional filters
Returns the voices for the voice field of make_ugc, or of make_faceless
when called with skill: "make_faceless". Each voice names the one skill that
speaks it in skill, and the other skill refuses it before any charge. The
make_ugc voices are the presets, the thirty voices of gemini-3.8-flash-tts
and the catalog voices, by language and then by rank. The seventeen
make_faceless voices are a list of their own. Costs nothing and creates no
run.
All filters are optional:
skill—make_ugcormake_faceless: the skill whose voices to list. Without it the list holds themake_ugcvoices only, so call it withskill: "make_faceless"before a faceless video.language— the voice's native language. It is a filter, not a limit: any voice speaks any supported language. Amodel_voicevoice has no language of its own and matches the languages measured on its model,enandru; the filter also picks the language of its sample.gender—femaleormale.ageanduse_case— labels as the entries print them, such asyoungornarrative_story.model— keeps the voices that speech model speaks: the presets and catalog voices whose language it supports, or the model's own voices.model: "gemini-3.8-flash-tts"returns its thirty voices.
A voice without a label matches no value of that filter; language on a
model_voice voice is the one exception above. An unknown value is
refused with the allowed values, and an unknown filter name with
unknown_field; neither returns an empty list.
Each entry carries name, kind (preset, model_voice, faceless_voice or
catalog), skill, language, description, model (its default speech
model) and supported_models. A model_voice entry has no language, names one model in
both model and supported_models and carries gender only where it is known
(kore is female, charon is male). A faceless_voice entry carries its
language and gender and the model label narrator-v1, which is not a
tts_model: the model filter never returns it. A
catalog voice also carries verified_models and whichever of locale,
accent, gender, age and use_case the catalog has. The tool returns
{ "voices": [...] } only. REST GET /v1/voices also returns models, and the
script limit of each model is in tools/list.
There is still a network call: the tool hits OUR /v1/voices and needs a working
CLIPWRIGHT_API_KEY, like the other three. Nobody calls the vendor — the list
lives in @clipwright/core and the samples in our own bucket, so this surface
needs no vendor key.
Call it before make_ugc when the user asks for "a different voice": a voice
name is preferable to a raw voice_id, because a name survives a change of
vendor and an identifier does not.
Let the user HEAR the voice before paying for it. Every voice with a recorded
sample carries preview_url: a short clip spoken by that voice with its own
model, so it matches what the render will sound like. A catalog voice speaks its
sample in its native language. A model_voice voice has two samples: the English
one comes by default and the Russian one with language: "ru". Descriptions convey neither
diction nor accent nor pace; the owner caught a voice-face mismatch by ear, on a
finished clip. Offer the sample whenever the user asks for a different voice.
The link is signed and expires at preview_expires_at, an hour out. For a fresh
one call list_voices again; never store it. A voice with no sample carries no
preview_url at all — the sample does not exist, rather than having been dropped
from the answer.
get_account — free, no input
Returns the body of GET /v1/account: balance_credits, debt_credits,
holds_credits and the unexpired grants. It costs nothing and creates no run.
debt_creditsabove zero blocksmake_ugcwithdebt_outstanding, whatever the balance, until the account buys credits.- With no debt,
balance_creditsis what a new run can spend. holds_creditsare reserved for runs in progress; they settle when those runs finish.
quote_ugc already warns in warnings[] when make_ugc with the same input
would be refused for money or access, so a render rarely needs this call first.
Call it when the user asks how many credits are left, or to show the numbers
behind such a warning.
quote_faceless — free, and make_faceless — paid, creates a run
A faceless video is narration over an opening animated clip and image scenes,
without an on-camera presenter. You select 30 to 90 seconds; the video ends with
its narration, runs at least 25 seconds and at most a quarter past the selection,
never beyond 90. Captions are on by default; pass
captions: false to turn them off.
Give the narration one of two ways, and name which with input_mode:
input_mode: "script"withscript— your exact text is read as written. The video ends with the narration, runs at least 25 seconds and may exceedduration_secondsby up to a quarter, never beyond 90 seconds; a video more than 5 seconds longer says so inwarnings[]. A script is accepted when its estimated length is 21 seconds toduration_secondsplus 5, at most 90. The charge follows the delivered duration and never exceeds the quote; the minimum is 200 base credits plus 150 for the opener.input_mode: "brief"withbrief— a short description; the narration is written from it.
Send exactly one of script and brief, the one that matches input_mode. The
tool schema lists both as optional because MCP cannot express "one or the
other"; the server and this client both refuse a mismatch, and the client
refuses it before any request is sent.
duration_seconds (30 to 90, on a 1/25 s frame boundary) is required. Optional:
style_reference (a public https image the scenes follow in style),
character_reference (a public https image of a person or figure to keep
consistent) and scene_images (your own images, each anchored to a word range
or a quote of the narration). An uploaded image must belong to this account.
voice picks the narration voice: a name from list_voices with
skill: "make_faceless". There are seventeen, in English, Russian, Spanish,
Portuguese, French and Italian; each entry has a language and a gender.
Without voice the narration is read by narrator_en_wise_lady, an English
voice, whatever the language of the text, so pick a voice in the language of
the narration. A voice reads any language with its own accent; when the
language of a voice you named is written in another alphabet than the script,
the quote and the run say so in warnings[]: the voice may mispronounce the
text, and the video may come out longer than its length limit and fail. A
brief draws that line, worded as a condition, only when it is written in the
other alphabet and holds no letter of the voice's: the narration is written in the language of the brief
unless the brief asks for another, so name the narration's language in the
brief when it differs from the brief's own. An unknown name and a make_ugc
voice such as george are refused before any charge. voice_id is not
accepted: make_faceless speaks only the voices listed for it. Both faceless
tools refuse voice_id and any other key they do not declare before any
request is sent. These voices have no preview_url yet.
Call quote_faceless first and show the user the price: it spends nothing and
warns when make_faceless would be refused for money or access. make_faceless
returns a run_id at once; poll get_run until succeeded (with video_url)
or failed. As with make_ugc, a repeated call with the same input returns the
same run, and attempt 2, 3, … deliberately starts a new paid one. attempt
belongs to make_faceless: a quote takes none, and quote_faceless refuses
it. When faceless generation is switched off on the server, both tools answer
paid_render_disabled and nothing is charged.
A succeeded faceless run can carry a remark from our own delivery check in
warnings[]: that the check could not confirm the captions or a scene change at
a given second, or that it did not run. The video is delivered and charged as
any other. Read the warning to the user and let them watch the video before
they publish it.
5. Retrying a failed run is NOT a permanent refusal
If get_run returned a terminal FAILED and you call make_ugc AGAIN with the
SAME input (same script and options) and WITHOUT changing attempt, the server
returns THAT SAME terminal failed run as it is (same error, same run_id) instead
of starting a new paid render. That is idempotency deduplication (section 2), not
a sign that the video is forever out of reach.
To actually retry, pass attempt one higher than before (the first call is
attempt=1 by default; the second attempt is attempt: 2, the third attempt: 3,
and so on). This is the behaviour documented verbatim in the make_ugc tool
description:
"Pass attempt=2,3,… to deliberately start a NEW run for the same input (retry after a failure)."
Technically attempt does not go into the render request body but into the SDK
options, and becomes a :N suffix on the idempotency key — a different suffix
gives a different key, so the server sees a NEW request, which means a new
run_id and a new paid vendor call.
Do not confuse this with CLIPWRIGHT_CLIENT_ID: attempt is the agent's
deliberate "this is a new attempt", while clientId is the fixed identity of the
installation and must NOT change between attempts.
⚠️ EVERY attempt BUMP COSTS MONEY
A new attempt is a new run and a new paid vendor call, not a free resend.
The loop "failed → bump → failed → bump" burns real credits on every iteration.
So:
- read the
errorof the failed run before retrying. Refusals such asrejected_field,aspect_conflictorscript_encoding_lostare NOT fixed by retrying — they are determined by the input, and the next attempt fails the same way, only now for money.script_encoding_lostin particular cannot be fixed at all by sending the same text again: the bytes were lost before the request reached us, so re-send the script decoded as UTF-8; speech_too_short_for_scriptis almost never fixed by retrying either. The vendor's pronunciation and stress are determined by the text; only the length varies, and only within the vendor's measured spread. A take just under the threshold may pass on a second try, but a take far under it will not — change the text or the voice instead;- bump
attemptonly for a refusal that looks temporary (vendor timeout, 5xx, a dropped connection); - no daily cap stands between you and your credits (0.7.0). Not on your
account, and not on the service either:
global_cap_exceededis no longer produced. What still stops a run before any paid call is your own balance, an unpaid debt, and our killswitch.
5.1. Server refusals are read from fields, not guessed (0.2.0)
Since 0.2.0 an API refusal carries structured fields instead of one free-form
sentence. It still arrives the way every tool result does — as JSON inside the
text block, with isError: true:
{ content: [{ type: "text", text: "{\"status\":\"FAILED\", …}" }], isError: true }
So JSON.parse(content[0].text) first, then read the fields below. Reading
content[0].balance_credits gives undefined: the numbers are inside the parsed
text, not beside it. Do not relay the raw text to the user and do not guess from
it.
{
"status": "RETRY_LATER" | "FAILED",
"code": "rate_limited" | "insufficient_credits" | "debt_outstanding" | "http_503" | …,
"message": "<human-readable text from the server>",
"retryable": true | false,
"next_action": "<what to do right now>",
"retry_after_seconds": 7,
"balance_credits": 3,
"required_credits": 8,
"debt_credits": 120
}
Numeric fields appear only where the refusal actually has them:
| Field | Appears on |
|---|---|
retry_after_seconds |
rate_limited, and any http_5xx where the server named a pause |
limit, window_seconds |
only when the 429 body followed the Clipwright contract; a 429 whose body did not carries neither |
balance_credits, required_credits |
insufficient_credits only |
debt_credits |
debt_outstanding only |
Treat every one of them as optional and read what is there. In particular, do not
expect balance numbers on debt_outstanding: that refusal reports the debt, and
the balance is not what blocks the run.
One rule: look at retryable.
retryable: true(rate_limited, 5xx) — waitretry_after_secondsand repeat THE SAME call with the same arguments. Do not bumpattempt: this is not a failed run but a refused request, and a newattemptwould mean a second paid render (section 5).retryable: false(insufficient_credits,debt_outstanding,account_not_admitted, other 4xx) — stop and tell the human. A repeat gives the same answer while eating into the rate limit. For money refusals, quote the NUMBERS: "the account has 3 credits, this needs 8" — without them the human cannot tell how much to buy.
account_not_admitted (403) is not a money refusal and carries no numbers: the
account has no beta access. Buying credits will not lift it — access is granted
by the operator. Say that, and do not offer to top up.
Do NOT promise a position in line or an upcoming batch. The refusal says one thing only: this account has no access right now. An account whose access was REVOKED gets the same 403, and promising it a turn would be a lie at the worst possible moment.
The two money codes are cured the same way — buy credits — but they must not be
conflated, because they describe different situations. insufficient_credits
means the balance is too small for this run. debt_outstanding means the account
owes credits, and it can owe them while still holding a balance, so telling the
human "you do not have enough credits" would be false. Credits bought while a
debt stands clear the debt first and only then reach the balance. Clearing the
debt lifts this particular refusal and nothing else: the run still needs enough
credits of its own, so a purchase equal to the debt clears it and adds nothing to
the balance. Quote the debt figure, and say that credits go to the debt first: a
purchase adds to the balance only the part that exceeds the debt. Whether the
human has to buy more than the debt depends on what the balance already holds,
and this refusal does not say — the balance is not what blocks the run.
Do not read a source into the refusal. A 429 or a 5xx can come from the
Clipwright API itself or from anything between you and it, and the fields above
cannot tell you which — so the report does not claim one, and neither should you
when relaying it. What is certain is the code and what to do about it.
About 429 specifically: it does not mean no run was created. The rate limit
sits in front of the handler, so a repeat carrying an already-used idempotency key
gets one too. Retry with THE SAME key — a new key is a new paid render.
Nobody waits on your behalf here — you do the polling. get_run makes one
call and hands you whatever came back, refusals included. So a 429 on a status
check is yours to sit out: wait retry_after_seconds, then call get_run again
with the same run_id. Do not treat it as a failed run and do not start a new
one — the run is already running and already paid for.
(The SDK's own makeUgc does wait these out internally, which is why the CLI and
direct SDK users never see them. The MCP tools are deliberately thin: make_ugc
returns as soon as the run starts, so a long render never blocks your turn.)
6. On failure — plain curl as a diagnosis splitter
If the MCP path does not get you to a playable video, hit the same public REST directly with curl, bypassing MCP, SDK and CLI entirely. If curl gets through, the packaging layer is at fault; if it does not, production or the network is.
Two commands, no repository needed (the package is installed from npm and carries no scripts):
TOKEN=cw_... # the same value as CLIPWRIGHT_API_KEY, section 1.3
API=https://api.clipwright.io
# 1. Start a run. The idempotency key is required and must be UNIQUE per call:
# a repeated key returns the earlier run instead of a new one (section 2).
# Generate it with Node, not uuidgen: Node is certainly here (the package is
# installed via npx), while uuidgen may be missing on a bare Linux image — and
# then the shell silently substitutes an empty string, the key becomes
# constant, and a second run returns the earlier one, ruining exactly the
# diagnosis you started it for.
curl -sS -X POST "$API/v1/skills/make_ugc/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: cw-$(node -e 'process.stdout.write(crypto.randomUUID())')" \
-d '{"script":"Hello! This is a test line."}'
# 2. Poll until terminal, every ~5–10 s.
curl -sS "$API/v1/runs/<run_id>" -H "Authorization: Bearer $TOKEN"
Reading the result:
- curl reaches
succeededwith its ownrun_idand a playable link, but MCP does not: the root cause is in the MCP packaging layer (build, host config, environment — section 1), not in the REST API and not in access or the token. - curl fails too: the problem is not MCP. Check
$TOKENfrom the commands above (shapedcw_*, not truncated when copied), network access to$API, and — for the cloned-run story — thatCLIPWRIGHT_CLIENT_IDreally is unique on this machine (section 2). If you get a 200 with arun_idthat is not yours in substance, that is the symptom of the collapse described in section 2 on the MCP path; curl (which uses its own randomIdempotency-Keyper call) will not reproduce it directly, but it is worth checking separately viaget_runon thatrun_id.
The second request above — GET /v1/runs/{id} — is free: you can poll an existing
run as often as you like without making a new paid call.
The whole sequence (what must reproduce)
- Install the server:
npx -y -p @clipwright/mcp-server@latest clipwright-mcpstarts and printsrunning on stdio(section 1.1). From source:pnpm install && pnpm buildin the repository root, thendist/index.jsexists. - Generate a unique
CLIPWRIGHT_CLIENT_ID(section 2) and put it, together withCLIPWRIGHT_API_KEY, into the MCP host config (section 1.3). - Restart the host and confirm
tools/listreturns 4 tools (make_ugc,quote_ugc,get_run,list_voices). quote_ugcwith a script → show the price AND readsource,resolved_aspect_ratio,warnings[](section 3).make_ugc→ get arun_idimmediately.get_runevery ~5–10 s, readingstatealong the way andstatus+stateon the terminal response, untilSUCCEEDED(video_url) orFAILED(error).- On failure — the same two curl commands from section 6 to split the diagnosis.
If you are a second user evaluating the product
You have one task: reach a playable video using this file, without spoken help from the owner. What is being tested is the completeness of the guide, not you — so do not ask, write down where you got stuck.
Before you start, make sure of two different things.
- The token was issued on YOUR account, not merely as a separate token. The
idempotency axis is
account_id, and one account can hold several live tokens. Your own account means another person's run cannot reach you at all. You cannot check this from your side: the account behind a token is visible only to the service. So the confirmation that the token was issued on a SEPARATE account comes WITH the token — it is part of handing it over, not a question for the owner. If it did not come, that is a gap in the instructions: write it down and move on, no need to ask. CLIPWRIGHT_CLIENT_IDwas not copied from anyone's machine (section 2). It matters WITHIN an account: if you and the owner ended up on one account (even under different tokens) AND yourclientIdmatched, the server returns THE OWNER'S RUN with a 200 and a playable link — a false success that looks more convincing than a real one. It can match through a sharedHOME, a copied value, or a cloned image; a genuinely separate installation has its own even without the environment variable.
Tell two classes of failure apart, because they are fixed differently:
rejected_field,unknown_field,aspect_conflict— a contract doing its job, not a defect. Read the error text: it names both the reason and the replacement;- everything else (timeout, 5xx, empty response, tool not found) — a candidate for a real defect. Here, run the same two curl commands from section 6: if curl reaches a playable link and MCP does not, the cause is in the MCP packaging layer, and that should be written down separately from the rest.
