# Mutator: full documentation > Mutator is an AI app for UGC-style videos and slideshows for TikTok and > Instagram. You make a format once, make as many posts from it as you need in > one sitting, and schedule the ones you want. Nothing posts until a person > schedules it. This file is every publicly documented part of Mutator, concatenated. The index with links is at https://mutator.app/llms.txt. --- # Public API (Studio) Drive Mutator from your own systems: start runs, watch them and read what they did. > **Connecting an AI agent?** Use the MCP server at `https://mutator.app/mcp` > instead — same keys, and nothing it can call posts anything. See > [the MCP documentation](https://mutator.app/docs/mcp). ## Keys Settings → API keys → **New key** (workspace owner, any paid plan). The secret is shown **once** and stored only as a SHA-256 hash — there is no reveal path. Lose it and you issue a new one. Two scopes: | Scope | Can do | | ------- | ------ | | `read` | List automations and runs, read run detail and analytics | | `write` | Everything in read, plus start runs | A key names exactly one workspace. There is no cross-workspace access, and a workspace that drops to a plan without keys loses API access immediately — keys are re-checked against the plan on **every** request, not at renewal. Revoking takes effect at once. ### What a key reaches depends on the plan Keys come with any paid plan, and the plan still decides what there is to reach. Formats are on every paid plan: `/api/v1/automations` lists them, and starting a run on one that is not switched on (every format on Pro, and any draft) makes from its latest draft as a dry run, the way Make does in the app: it makes and publishes nothing, and a person schedules what posts. The deeper parts of the analytics report are withheld from a plan that does not include them, the same way they are on the screen. The trial is the one paid-feature tier with no keys. ## Authentication ```bash curl https://your-host/api/v1 \ -H "Authorization: Bearer loop_sk_..." ``` `GET /api/v1` returns the workspace the key belongs to (its id and its name), the key's scope, and the endpoint list — use it as a connectivity check before wiring anything up, and as the label for a saved connection. The API sends no CORS headers, so a browser page cannot call it: call it from a server, where the key stays secret. ## The machine-readable version `GET /api/v1/openapi.json` is this API as an OpenAPI 3.1 document, and it needs no key: it describes the surface and says nothing about any workspace, so an importer can read it before anybody has an account. It is generated from the same schemas the server answers through, not maintained beside them, so the shapes in it are the shapes you get. Point Zapier's importer at it, generate a client from it, or hand it to an agent. ## Endpoints | Method | Path | Scope | | --- | --- | --- | | GET | `/api/v1` | read | | GET | `/api/v1/automations` | read | | GET | `/api/v1/automations/{id}/runs` | read | | POST | `/api/v1/automations/{id}/runs` | write | | GET | `/api/v1/runs` | read | | GET | `/api/v1/runs/{id}` | read | | GET | `/api/v1/media` | read | | GET | `/api/v1/media/{id}` | read | | GET | `/api/v1/posts` | read | | GET | `/api/v1/generations` | read | | POST | `/api/v1/assets` | write | | POST | `/api/v1/generations` | write | | GET | `/api/v1/generations/{id}` | read | | GET | `/api/v1/hooks` | read | | POST | `/api/v1/hooks` | read | | DELETE | `/api/v1/hooks/{id}` | read | | GET | `/api/v1/approvals` | read (always empty, see below) | | POST | `/api/v1/approvals/{id}/decide` | write (refused, see below) | | GET | `/api/v1/analytics?days=30` | read | ### Starting a run `idempotencyKey` is **required**, so retrying after a timeout is safe: ```bash curl -X POST https://your-host/api/v1/automations/AUTOMATION_ID/runs \ -H "Authorization: Bearer loop_sk_..." \ -H "content-type: application/json" \ -d '{"idempotencyKey": "weekly-2026-08-12", "dryRun": false}' ``` Replaying the same key returns the original run rather than starting a second one. Keys are namespaced per API key, so two integrations cannot collide. Runs started this way are recorded with trigger `api`, so run history shows they came from an integration rather than a person. ### Paging Every list takes `limit` (25 by default, 100 at most) and `after`, and returns `nextCursor` beside the rows. Pass that value back as `after` for the next page; `null` means you have reached the end. ```bash curl "https://your-host/api/v1/media?limit=50" -H "Authorization: Bearer loop_sk_..." # { "media": [ … ], "nextCursor": "MjAyNi0wOS0yM1QwODoxNTozMC4xMjNafGFydC0x" } ``` A cursor names a row rather than a position, which matters if you are polling. With `?page=2`, anything created between your two requests shifts the list down and you skip exactly one item, silently. A cursor is "everything older than this row", so nothing is lost and nothing repeats. Cursors are opaque: do not build one, and a value we did not write is a 400 rather than a fresh first page. ### What Mutator made `GET /api/v1/media` is the list to poll if you want the files: everything this workspace has produced, in a run or in Generate, newest first. ```json { "media": [ { "id": "art_…", "kind": "video", "title": "Kitchen countertop, 9:16", "mime": "video/mp4", "sizeBytes": 4318221, "createdAt": "2026-09-23T08:15:30.123Z", "runId": "run_…", "automationId": "aut_…", "durationSeconds": 8, "aspectRatio": "9:16", "downloadUrl": "https://…", "posterUrl": "https://…" } ], "nextCursor": null } ``` Filters: `kind` (`image`, `video`, `image_set`), `automationId`. `GET /api/v1/media/{id}` is the same thing for one file. It is what to call when a `media.created` webhook arrives: the event carries ids rather than a link, because a delivery can wait on a queue and a six hour old link would be useless by the time anything fetched it. Ask for the file and the link is signed then. A file that has since gone, or that the content check withheld, answers 404 rather than handing back a row with nothing to fetch. Files somebody uploaded are not here. "What Mutator made" is the question this answers; a file you brought is already wherever you brought it from. **Links expire after six hours.** Longer than the hour a preview in the app gets, because these are fetched by a queue somewhere else, and short enough that a URL in a spreadsheet stops working. Fetch the bytes, or ask again. **Anything the content check withheld is not listed at all**, rather than listed with no link. Mutator's own screens draw a withheld file as a tile saying why, which is right for the person who made it and useless to a Zap that was going to copy it somewhere. `GET /api/v1/runs/{id}` now carries `outputs`: the same media, plus any caption set with its words in `content`, because a post is a file and the words that go with it. Briefs and analytics summaries are the run thinking out loud and stay out of it. `GET /api/v1/generations` is the other half: the pictures and clips somebody asked for directly, with no automation. `status` is `queued`, `generating`, `done` or `failed`; a failure carries `error` and, when paying would change the answer, `errorCode`. Credits are held when the work starts rather than when it is asked for, so running out appears here rather than as a 402. `GET /api/v1/runs` lists runs across every automation, newest first, filtered by `status` or `automationId`. Use it rather than walking the automations one at a time. ### What went out `GET /api/v1/posts` lists what a platform actually accepted, newest first. ```json { "posts": [ { "id": "pub_…", "platform": "tiktok", "url": "https://www.tiktok.com/@example/video/…", "publishedAt": "2026-09-22T09:00:00.000Z", "mock": false, "mediaArtifactId": "art_…", "captionArtifactId": "art_…", "runId": "run_…", "automationId": "aut_…", "variantLabel": null } ], "nextCursor": null } ``` Filters: `platform`, `automationId`. Paged like the rest, on `publishedAt`, which is this record's clock. **Nothing in this API makes one of these.** It reports what a person decided in Mutator. The rows are kept deliberately free of the automation and the run that produced them, so a post that went out last year is still listed after the automation behind it has been deleted. A post made in test mode is listed with `mock: true` rather than left out, so that an integration wired up in test mode sees something rather than concluding it is broken. `captionArtifactId` and `mediaArtifactId` name what went with it; fetch the file with `/api/v1/media/{id}`. ### Asking for a picture or a clip ```bash curl -X POST https://your-host/api/v1/generations \ -H "Authorization: Bearer loop_sk_..." \ -H "content-type: application/json" \ -d '{"idempotencyKey": "sheet-row-412", "kind": "video", "prompt": "The mug on a windowsill, steam rising", "aspectRatio": "9:16"}' # { "id": "gen_…", "deduplicated": false } ``` The same request the Generate panel makes: `kind` (`image` or `video`), `prompt`, and optionally `aspectRatio`, `look`, `quality`, `variations` (1 or 4, because the provider batches ones and fours), `duration` for a clip, `caption`, and the ids of a picture to animate, a character, a product or an environment. Nothing waits for the provider. Poll `/api/v1/generations/{id}` or subscribe to `generation.finished`. **`idempotencyKey` is required**, because every integration platform retries a request that timed out, and a timeout after the work started looks exactly like one before it. Asking again with the same value returns the first answer and `deduplicated: true` rather than making a second video. Keys are namespaced per API key, so two integrations cannot collide. This is attributed to whoever issued the key: it is what the workspace's own record of the work will say, and it is where the credits are spent from. ### Bringing a picture in ```bash curl -X POST https://your-host/api/v1/assets \ -H "Authorization: Bearer loop_sk_..." \ -H "content-type: application/json" \ -d '{"idempotencyKey": "drive-file-88", "url": "https://.../mug.png", "title": "Blue mug", "referenceRole": "product"}' # { "id": "art_…", "mime": "image/png", "sizeBytes": 208411, "status": "checking", … } ``` A link, not an upload: it is what a Zap or a Make scenario holds, and the browser's own path is a signed PUT that only a browser can make. We fetch it. PNG, JPEG, WebP and GIF, up to 32 MB, decided by the file's first bytes rather than by the `content-type` the same server sends. Bigger files and video go in through Mutator, which streams them. The address must be a public one, and a redirect is refused rather than followed: send the link to the file itself. **The picture is not usable yet.** `status` is normally `checking`, because everything that arrives goes past the content check before anything may paint from it. Wait for `media.created`, or poll `/api/v1/media` for the id. Then pass the id to `/api/v1/generations` as `imageArtifactId`, `productId`, `characterId` or `environmentArtifactId`. On a video, `imageArtifactId` is the clip's first frame: the picture is moved as it is, silent, in its own shape. Send `imageUse: "reference"` beside it to have the picture used as a reference instead: the clip is made from the prompt, in the `aspectRatio` you ask for, with the picture to go by, and does not have to open on it. An image is always painted from its picture. `idempotencyKey` is required here too, and a retry returns the file the first call stored rather than a second copy of it. ## Webhooks Rather than polling, ask to be told. ```bash curl -X POST https://your-host/api/v1/hooks \ -H "Authorization: Bearer loop_sk_..." \ -H "content-type: application/json" \ -d '{"event": "media.created", "targetUrl": "https://hooks.example.com/abc"}' # { "id": "…", "event": "media.created", "targetUrl": "…", "secret": "whsec_…", … } ``` `DELETE /api/v1/hooks/{id}` stops it, and `GET /api/v1/hooks` lists what this key is listening for, with `disabledAt` and `lastError` when we have given up on one. A **read** key is enough. A subscription sends the workspace's own data to an address its holder chose, which is no more than that key can already read, and requiring a write key would mean every trigger-only integration held one that can also start runs. Subscribing twice with the same event and address is one subscription, so a platform that re-subscribes when somebody edits an automation does not double every delivery. | Event | When | | --- | --- | | `media.created` | A picture or clip finished and passed the content check | | `run.finished` | A run reached a final state, including a failed one | | `generation.finished` | Something asked for in Generate finished or failed | | `post.published` | Something a person scheduled actually went out | `post.published` says that something went out. It is not a way to send one: nothing in this API publishes, and a post exists only because a person put a file in an account's schedule with their name against the decision. ### The delivery ```http POST /abc HTTP/1.1 mutator-event: media.created mutator-delivery: 0b2f… mutator-signature: t=1790000000,v1=6f3c… content-type: application/json { "id": "0b2f…", "event": "media.created", "workspaceId": "…", "createdAt": "2026-09-23T08:15:30.123Z", "data": { … } } ``` Answer with any 2xx. Anything else, or a timeout after ten seconds, is a failure and we try again with the queue's backoff. **Deliveries are at-least-once**: dedupe on `id`, which is also in the `mutator-delivery` header. The payload carries ids and a few fields, never the bytes. Fetch the file from the link in `/api/v1/media` or the run's `outputs`. **Check the signature.** It is HMAC-SHA256 of `${t}.${body}` with the secret that subscribe returned, written `t=…,v1=…`. Compare in constant time, and reject a `t` that is not recent, which is what stops a delivery you received today being replayed at you tomorrow. ```js const [, t, sig] = /^t=(\d+),v1=([a-f0-9]+)$/.exec(header); const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); ``` ### When we stop Fifteen failed attempts in a row disables a subscription, and the workspace is told in Mutator. Nothing is lost on our side, and subscribing again with the same event and address switches the same subscription back on. A subscription also stops when the key that made it is revoked or expires, and while the workspace has no plan that includes API access. Revoking the key is the way to turn an integration off. ### Approvals were removed On 20 September 2026 the approval step was taken out. A run no longer stops and waits for a person, because a run no longer posts anything at all: what it generates goes to the library, and a post exists only once somebody has put a particular file in a particular account's schedule, in Mutator, with their name recorded against the decision. Both paths still answer rather than 404, so an integration written against them gets a reply it can read: - `GET /api/v1/approvals` returns an empty list. Nothing is waiting, because nothing can wait. - `POST /api/v1/approvals/{id}/decide` returns **410** with a message saying where the decision is made instead. There is no endpoint in this API that schedules a post, which means **no API key can cause anything to be published**. That is stronger than the rule it replaces: a write-scoped key used to be able to approve named items. If your integration relied on that, the run and its output are still yours to read; the posting is done by a person in the app. ## Errors JSON with `error` and `correlationId`. Quote the correlation id in support requests; it ties the response to the server logs. | Status | Meaning | | --- | --- | | 400 | Invalid body (an `issues` array names the fields) | | 401 | Missing, unknown, revoked or expired key | | 402 | Refused by the workspace's plan, credits, billing or a spending limit (see below) | | 403 | Read-only key attempted a write | | 404 | Not found *in this key's workspace* | | 410 | Gone: approvals were removed (see above) | | 422 | `content_blocked`: words or media Mutator does not make or publish (see below) | | 429 | Rate limited | | 503 | `screening_unavailable`: the content check could not answer, so nothing went ahead; retry later | | 5xx | Our side; safe to retry idempotent calls | A record belonging to another workspace returns **404, not 403** — existence is not disclosed across tenants. ### Refusals a purchase could change A 402 carries a `code`. The `error` sentence says what is true — "This run needs 40 credits and 12 are left." — and never what to buy, because the same sentence is shown in Mutator's phone apps. What would change the answer is the code, with `details`: | `code` | Means | `details` | | --- | --- | --- | | `plan_required` | The workspace has no plan it can run on right now | `reason`: `no_plan`, `trial_ended`, `plan_ended`, `trial_runs_used` (with `limit`), or `card_required` (the welcome credits are spent and the trial has not started) | | `plan_feature` | The plan does not include this, or not this many | `feature` (e.g. `advanced_triggers`, `members`); `requiredPlan` (`growth` or `studio`) when the plan lacks it entirely; `limit` and `used` for a counted allowance | | `credits_required` | Not enough credits for the work | `needed`, `available` | | `billing_attention` | A payment problem is holding things up | `reason`: `payment_failed` or `credits_on_hold` | | `limit_exceeded` | A limit no purchase lifts: a spending cap set in Mutator, a step's cost ceiling, the number of keys | none | ```json { "error": "Scheduled runs are part of Growth.", "code": "plan_feature", "details": { "feature": "advanced_triggers", "requiredPlan": "growth" }, "correlationId": "…" } ``` Plans and credits are changed by a workspace owner in Mutator; no endpoint here buys anything. An integration should stop and tell a person, not retry. Over MCP, the same refusal comes back as a tool result with `isError: true`: the sentence in the first text block, and `{"code", "details"}` as JSON in a second. A refusal that happens after the request has returned keeps its code, without details, beside its message: `error.code` on a failed step and on the run it failed, `errorCode` on a Studio generation, `faceErrorCode` on a character whose face sheet failed, and `code` on a notification about it (a paused schedule, a stopped repeat, a failed run, an ended trial or plan). Credits are held when a generation or a face sheet starts rather than when it is asked for, so running out shows up there and not as a 402. Each is optional and null for anything else. ### Refusals by the content screen Mutator does not make or publish nudity, sexual content, graphic violence or the other things its terms prohibit, and checks the words and media that pass through it. Two codes say what the check decided: | Status | `code` | Means | What to do | | --- | --- | --- | --- | | 422 | `content_blocked` | Something in the request, or on a step of the automation being run, describes what Mutator does not make | Stop and tell a person; the same request will be refused again | | 503 | `screening_unavailable` | The check could not be made, so nothing was saved, started or charged | Retry later with the same idempotency key | When the refusal is about a field somebody wrote, an `issues` array names it in the same shape a 400 uses. For a run, the path is the step's key and the field, e.g. `r2.extraGuidance`: ```json { "error": "“Strategy”: The guidance can't be used: it describes nudity, which Mutator doesn't make or publish.", "code": "content_blocked", "issues": [{ "path": "r2.extraGuidance", "message": "…" }], "correlationId": "…" } ``` A run is checked before anything is created or spent, and after every other refusal above: a workspace that is out of credits is told that first. Over MCP, both come back as a tool result with `isError: true`: the sentence in the first text block, and `{"code", "issues"}` as JSON in a second (`issues` only when the refusal names a field). A step that fails for either reason once the run has started keeps the code on its stored error — `error.code` on the step and on the run it failed — without `issues`. An output withheld after it was generated is not charged for. ## Rate limits Per key, so one integration cannot exhaust another's budget: 120 reads/minute, 30 writes/minute. ## Versioning The path carries the version. `/api/v1` will not change incompatibly; breaking changes ship as `/api/v2`. --- # Mutator in ChatGPT Create and schedule UGC content directly from ChatGPT. Ask for a post, a picture or a clip in plain words, see the price, see what was made in the chat, and open it in Mutator's Add to schedule with the files already picked. Server: `https://mutator.app/mcp/chatgpt` ## What it does 1. **Connect** your Mutator account. ChatGPT opens Mutator, you sign in, pick the workspace and choose whether ChatGPT may make content, which spends credits. Connected apps are listed under **Settings → API keys**, where each can be disconnected. 2. **Make** UGC content with what is already in your workspace: - a whole post from a format, as a slideshow or a video, made the way the Formats tab makes one; - one or four pictures, or a clip of 3 to 15 seconds, from Generate; - with your products and AI characters in them. 3. **See** it in the chat: pictures, clips and slideshow posts, as they finish. 4. **Schedule** it in Mutator. ChatGPT gives you a link that opens Add to schedule with the files you chose already picked. You choose the accounts and the times there, and answer TikTok's questions where it asks them. ## What it will not do - **It does not post or schedule anything.** A post exists only once you have picked the file, the account and the time in Mutator. ChatGPT can take you to that screen; it cannot press the button. - **It does not spend without asking.** Every piece of content is priced first, and ChatGPT is told to ask you before making it. The price you agree to is sent with the request, and if making it would cost more, nothing is made. - **It does not sell anything.** If the workspace is short of credits, ChatGPT says how many are needed. Credits and plans are managed in Mutator. - **It has no trend data.** Formats are Mutator's own catalogue; none of them is described as trending or viral. ## Tools | Tool | Changes anything | What it does | | --- | --- | --- | | `get_workspace` | No | Credits, brands, products, AI characters, formats and connected accounts | | `quote_content` | No | What a piece of content costs, before anything is made | | `create_content` | Spends credits | Makes a picture, a clip or a post from a format, at the price agreed | | `get_content` | No | Status and previews of what is being made, with a gallery in the chat | | `prepare_schedule` | No | A link to Add to schedule in Mutator with the chosen files picked | `create_content` needs the permission to make content, which you can leave unticked when connecting. Without it, ChatGPT asks you to connect again. ## Costs, waiting and retries - **Prices** are the same as in Mutator: Generate's prices for pictures and clips, and a format's test run price for a post. - **Making takes time**: about a minute for pictures, a few minutes for clips and posts. ChatGPT shows progress when you ask, or with Check again in the gallery. - **Nothing is charged twice.** Every request carries a key; sending the same request again with the same key returns what was already started. - **A failure says why**, and the credits held for it are returned. Trying again is a new request with a new price to agree to. - A post from a format is kept as a draft format in your workspace, so you can open it in Mutator, change it, or make a batch from it there. It never makes anything by itself. ## Who can connect The workspace's owner, on any paid plan, like an API key. A connection reaches one workspace and stops working when it is disconnected, revoked, or the workspace leaves a paid plan. --- # MCP server Connect an AI agent — Claude, ChatGPT, Cursor, anything that speaks Model Context Protocol — to a Mutator workspace, so it can find automations, test them, read what happened and report what came out. Endpoint: `https://mutator.app/mcp` ## The one thing to know **No tool on this server can post anything.** That is not a scope you can widen or a permission you can grant. It is the shape of the server. An agent can build a run, watch it, read what came out and tell you about it; a person opens Mutator and decides whether any of it reaches a real account. Nor can the public API, since approvals were removed on 20 September 2026: a run publishes nothing at all, and a post exists only once a person has put a particular file in a particular account's schedule, in Mutator, with their name on the decision. There is no key of any scope that reaches that. See what marketing may claim about this, and what the platform reviews were told. ## Connecting Two ways in, and both reach exactly the same tools: - **OAuth**, for clients that are given only the address: Claude and ChatGPT connectors, Claude Code, and any client that follows the MCP authorization spec. The client opens Mutator in a browser, the workspace owner signs in, picks the workspace and chooses whether it may start runs. - **An API key** as a bearer token, for clients configured with a header: the same keys the public API uses, created under **Settings → API keys**. See [the public API documentation](https://mutator.app/docs/api), which says what a key reaches on each plan. Either needs the workspace owner, on any paid plan. `read` reaches every tool except the four that write: `mutator_start_run`, `mutator_create_automation`, `mutator_save_automation_graph` and `mutator_set_schedule`. Give read only unless the agent genuinely needs to build or run things. Nothing either way can publish. ### Claude or ChatGPT In Claude, add a custom connector under **Settings → Connectors**. In ChatGPT, add a connector with OAuth. Paste `https://mutator.app/mcp` and nothing else, then approve it in the window Mutator opens. Connected apps are listed under **Settings → API keys**, where each can be disconnected. To make content in ChatGPT rather than build automations, use [Mutator in ChatGPT](https://mutator.app/docs/chatgpt) instead: its own server, `https://mutator.app/mcp/chatgpt`, with five tools for making, previewing and handing content to Add to schedule. ### Claude Code ```bash claude mcp add --transport http mutator https://mutator.app/mcp ``` Then run `/mcp` in Claude Code and sign in. To use a key instead, add `--header "Authorization: Bearer loop_sk_..."` to the command. ### Anything with a JSON config ```json { "mcpServers": { "mutator": { "type": "http", "url": "https://mutator.app/mcp", "headers": { "Authorization": "Bearer loop_sk_..." } } } } ``` ### How OAuth works here Mutator is its own authorization server and `/mcp` its only protected resource, following the MCP authorization spec (2025-06-18 and later): - A request without a key or token gets `401` with `WWW-Authenticate: Bearer resource_metadata="https://mutator.app/.well-known/oauth-protected-resource/mcp"`. The handshake included: a client that got a `200` from `initialize` would never offer to sign in. - Metadata: `/.well-known/oauth-protected-resource` (RFC 9728) and `/.well-known/oauth-authorization-server` (RFC 8414). - Clients register themselves at `/oauth/register` (RFC 7591). Public clients use PKCE alone; a client that names no auth method gets a secret. - `/oauth/authorize` takes the code flow with PKCE (`S256` only) and a `resource` of `https://mutator.app/mcp` (RFC 8707). The person approves on `/connect`, which shows where the answer will be sent. An app's name is its own claim; the redirect address is fixed when it registers. - `/oauth/token` issues an access token for an hour and a refresh token for 90 days, rotated on every use. A refresh token presented again after it was traded in ends the connection. `/oauth/revoke` (RFC 7009) ends it too. - An access token is accepted at `/mcp` only, never by `/api/v1`, and stops working the moment the connection is revoked or the workspace leaves a paid plan, like a key. Check it works by asking the agent to call `mutator_whoami`, or from a shell: ```bash curl -s https://mutator.app/mcp -H "Authorization: Bearer loop_sk_..." -H "content-type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` `GET https://mutator.app/mcp` needs no key or token and returns a description of the server and its tools — useful for checking reachability, and for anything cataloguing MCP servers. That same URL opens a page in a browser. One address serves both: a request asking for `text/html` gets the page, and everything else — a POST, or a GET sending `*/*` or `application/json` — gets the protocol. Nothing about the endpoint changed for a client that was already using it. ## Building an automation An agent reads the vocabulary before it writes: `mutator_list_brands` for the brand the automation belongs to, `mutator_list_step_types` for the steps that exist, `mutator_list_formats` for the content formats, and `mutator_list_connections` for the account a publish step must name — there is no "post to TikTok" field, only "post to this connection, which happens to be TikTok". Then `mutator_create_automation` with a graph of `{ key, type, position, config }` nodes and `{ sourceKey, targetKey }` edges. Most settings have defaults, so `config: {}` is usually enough. Two branches out of one step is how the same idea is produced two ways. An invalid graph is **saved anyway**, with the problems returned as `issues`. That is deliberate: half-built is a normal state for a draft, and failing the whole call would lose the work. Two settings are refused outright at save time rather than at run time, because otherwise the executor would refuse them later, in the middle of somebody's batch: several versions of one video, and an image batch that is not 1 or exactly 4. ## Tools | Tool | Scope | What it does | | --- | --- | --- | | `mutator_whoami` | read | Which workspace this key reaches and what it may do | | `mutator_list_automations` | read | Every automation with id, name and status | | `mutator_get_automation` | read | One automation: status, schedule, spending limits | | `mutator_list_runs` | read | Recent runs of one automation | | `mutator_get_run` | read | One run, with per-step detail | | `mutator_list_approvals` | read | Always empty: approvals were removed | | `mutator_get_analytics` | read | How published content performed | | `mutator_get_spend_limits` | read | Daily and monthly ceilings, and what is left | | `mutator_start_run` | write | Start a run. **Dry by default** | | `mutator_list_brands` | read | The brands an automation can belong to | | `mutator_list_connections` | read | Connected accounts, and the id a publish step needs | | `mutator_list_formats` | read | The content formats, and the niches they suit | | `mutator_list_step_types` | read | Every step an automation can be built from | | `mutator_create_automation` | write | Create an automation. **Draft only** | | `mutator_save_automation_graph` | write | Replace its steps. **Draft only** | | `mutator_set_schedule` | write | Set when it would run. **Saved switched off** | ### Dry by default `mutator_start_run` runs dry unless the caller passes `dryRun: false`. A dry run skips publishing (and scheduling a repeat), not generation. It is **not free**: `create` and `understand` ignore `dryRun`, so a dry run makes the same paid generation calls a real run does, costs the same credits or provider charges, and is checked against the same spend caps (`src/server/engine/runs.ts`, the comment above `assertSpendWithinCaps`). The public API leaves `dryRun` undefined and lets the engine decide, which is right for an integration somebody wrote on purpose. Here the caller is a model that may have inferred the entire call from one sentence, so the safe reading of silence is "test it": whatever it generates stops short of any social account. That limits what a mistaken call can publish, not what it can spend, which is why the tool description tells the model to start a run only when the user wants one. `idempotencyKey` is required, 8–80 characters, and namespaced per key on the way through. Retrying with the same value returns the original run rather than starting a second one. ## What this server is not - **Not an activator.** An agent can build a format and save it as a draft, and make from it the way Make does in Mutator: `mutator_start_run` on a format that is not switched on runs its latest draft as a dry run, which makes things and publishes nothing. It cannot switch one on, and nothing it makes posts until a person schedules it in Mutator. - **Not a publisher.** Above. - **Not a trend service.** There is no virality, trending or discovery data in this product. `mutator_list_formats` returns a hand-written catalogue, and a niche orders it rather than filtering it. An agent asked for "viral formats" can offer these; it cannot say any of them is trending. - **Not stateful.** No session id is issued, so any replica answers any request and a deploy loses nothing. Each call carries its own key. ## Implementation notes The server speaks JSON-RPC directly rather than using the official SDK. MCP is JSON-RPC 2.0 with four methods that matter to a tools-only server — `initialize`, `tools/list`, `tools/call`, `ping` — and the SDK's value is in transports that assume a long-lived process owning a socket. A Next route handler is neither. Authentication, rate limiting and error shaping are shared with the public API, and the endpoint carries no scope of its own: reaching it needs only a valid key or token, and the write tools check the scope themselves at call time. Requiring `write` at the door would lock read-only keys out of `initialize`. No CORS headers, deliberately. Every MCP client that matters connects from a server or a desktop process, and authentication is a bearer token rather than a cookie, so there is nothing for a browser to be tricked into sending. The protocol versions spoken are `2025-06-18`, `2025-03-26` and `2024-11-05`. An unknown version is answered with the newest rather than refused. --- # Mutator and Zapier Make videos and pictures from a Zap, and hear about them when they are made. > **Nothing here publishes anything.** There is no action in this integration > that reaches a social account, because there is no endpoint in Mutator's API > that schedules a post. A post exists once a person has put a particular file > in a particular account's schedule, in Mutator, with their name against the > decision. ## Where it stands The Mutator app is written and is not in Zapier's directory yet. Everything below works today through Zapier's own **Webhooks by Zapier**, against the same API the app uses, and moves over unchanged when the app is listed. ## Connecting Settings → API keys → **New key** in Mutator. Keys come with any paid plan and the secret is shown once. A **read** key is enough to watch for things and to read them. The three actions that make things need a **write** key. Every paid plan can list its formats and start a run on one. A format that is not switched on (every format on Pro) is made from its latest draft as a dry run, the way Make does: it makes and publishes nothing. ## Triggers | | Fires when | | --- | --- | | New Video or Picture | Mutator finished one and it passed the content check | | Automation Run Finished | A run ended, whichever way | | Generation Finished | Something asked for with the Generate action is ready, or failed | | Post Published | A post somebody scheduled actually went out | Each one fetches the thing itself when a delivery arrives, rather than reading the delivery's copy of it. A delivery can sit on a queue while a receiver is down, and a signed link that old is a broken Zap. It also means the fields your Zap sees are the fields the API gives, so the sample you build against and the real delivery cannot drift apart. ### Doing it today, without the app Add **Webhooks by Zapier → Catch Hook** as the trigger. Copy the URL it gives you, then subscribe it once: ```bash curl -X POST https://mutator.app/api/v1/hooks \ -H "Authorization: Bearer loop_sk_..." \ -H "content-type: application/json" \ -d '{"event": "media.created", "targetUrl": "YOUR_CATCH_HOOK_URL"}' ``` The events are `media.created`, `run.finished`, `generation.finished` and `post.published`. The reply carries a `secret`, and every delivery is signed with it; [the API documentation](https://mutator.app/docs/api) has the check to run before trusting one. `DELETE /api/v1/hooks/{id}` stops it. A delivery carries ids rather than links. Add a **Webhooks by Zapier → GET** step on `https://mutator.app/api/v1/media/{{id}}` to read the file, which is what the app's triggers do for you. ## Actions **Generate a Video or Picture.** A row, a form or a message becomes a prompt. It answers at once with an id, because a clip takes minutes and an action that waits is one that times out. Pair it with the Generation Finished trigger. **Start an Automation.** Runs one you have already built and switched on. **Add a Picture from a URL.** A new product photo becomes a reference the next generation can paint from. PNG, JPEG, WebP or GIF, up to 32 MB, fetched by Mutator from a public link. It answers `status: checking`, because everything arriving goes past the content check before anything may paint from it. ### Doing it today, without the app **Webhooks by Zapier → Custom Request**, POST, with your key in the Authorization header. Every endpoint and its fields are in [the API documentation](https://mutator.app/docs/api). ## Retries, and why every action asks for a key Every write in Mutator's API takes an `idempotencyKey`, because a request that timed out after the work started looks exactly like one that timed out before it, and the work is a video somebody paid for. The app fills one in for you from the Zap, the input and the hour. Two runs an hour apart are two requests; two identical runs inside one hour are one. Where that is wrong, set the Idempotency key field to something unique from your trigger, a row id or a file id. Doing it by hand, send one yourself and use something stable from the trigger. ## When something is refused Mutator answers a refusal with a sentence you can act on and a code saying what would change the answer. The app turns each into the right kind of Zapier failure: a rate limit waits and tries again, words the content check refused halt the task because trying again will be refused the same way, and credits or a plan fail loudly, because only you can fix that one. Doing it by hand, read `code` in the body. The table is in [the API documentation](https://mutator.app/docs/api). ## Limits 120 reads and 30 writes a minute, per key. Download links last six hours: fetch the bytes rather than storing the link. --- # Mutator and n8n Make videos and pictures from an n8n workflow, on your own machine, and start a workflow when Mutator has made something. > **Nothing here publishes anything.** No operation reaches a social account, > because there is no endpoint in Mutator's API that schedules a post. A post > exists once a person has put a particular file in a particular account's > schedule, in Mutator, with their name against the decision. ## Where it stands `n8n-nodes-mutator` is written and is not on npm yet. Everything below works today with n8n's own **HTTP Request** and **Webhook** nodes, against the same API the node uses. When it is published: **Settings → Community nodes → Install**, `n8n-nodes-mutator`, on your own n8n. On n8n Cloud, only once the node is verified, which needs a publish carrying an npm provenance statement. ## Connecting Settings → API keys → **New key** in Mutator. Keys come with any paid plan and the secret is shown once. The credential takes the key and, if you are pointing at something other than production, a base URL. A **read** key is enough for the trigger and the searches. The operations that make things need a **write** key. Every paid plan can list its formats and run one. A format that is not switched on (every format on Pro) is made from its latest draft as a dry run, the way Make does: it makes and publishes nothing. ## The trigger polls, on purpose Mutator delivers signed webhooks, and the trigger node asks instead. Most n8n runs on somebody's own machine or inside their own network, where a webhook from the outside cannot reach it, and a trigger that only works for the reachable half is one that fails silently for everybody else. It watches one of four things: a video or picture was made, an automation run finished, a generation finished, or a post somebody scheduled went out. That last one polls `/api/v1/posts` and follows `publishedAt`, which is that record's clock; the node cannot publish anything, it hears about it. Three things it does that are worth knowing: - **The first tick says nothing.** Switching a workflow on records where the list is rather than replaying everything the workspace has ever made. - **Runs and generations have to have finished.** A poll two minutes into a five minute video would otherwise announce it and hand the next node no file. - **Two things made in the same millisecond are two things.** It compares on the time and the id together, so neither is repeated or lost. ### If your n8n is reachable, use webhooks instead Put a **Webhook** node in the workflow, copy its production URL, and subscribe it once: ```bash curl -X POST https://mutator.app/api/v1/hooks \ -H "Authorization: Bearer loop_sk_..." \ -H "content-type: application/json" \ -d '{"event": "media.created", "targetUrl": "https://your-n8n/webhook/abc"}' ``` The events are `media.created`, `run.finished`, `generation.finished` and `post.published`. The reply carries a `secret`; every delivery is signed with it in a `mutator-signature` header, as `t=…,v1=…`, an HMAC-SHA256 of `${t}.${body}`. [The API documentation](https://mutator.app/docs/api) has the check to run before trusting one. `DELETE /api/v1/hooks/{id}` stops it. A delivery carries ids rather than links, so follow it with an **HTTP Request** on `/api/v1/media/{id}` to read the file. ## What the node does **Generation**: ask for a video or picture, get one, list them. Asking answers at once with an id, because a clip takes minutes and a node that waits is one that times out. Use the trigger, or a Wait node and then Generation → Get. **Media**: get one, list them. Getting one reads it now, so the download link is signed now. **Automation**: list them, run one. **Picture**: add one from a URL, so a generation can paint from it. It answers `status: checking`, because everything arriving goes past the content check first. ### Doing it today, without the node **HTTP Request**, with a Generic credential of type **Header Auth**: `Authorization` / `Bearer loop_sk_...`. Every endpoint is in [the API documentation](https://mutator.app/docs/api). ## Retries, and the idempotency key Every write in Mutator's API takes one, because a request that timed out after the work started looks exactly like one that timed out before it. The node fills it in from the execution id and the run index, which a retried execution keeps, so a retry is the same request rather than a second video. Set the field yourself where one workflow legitimately asks for the same thing twice. ## Limits 120 reads and 30 writes a minute, per key. Download links last six hours: fetch the bytes rather than storing the link. --- # Mutator and Make Make videos and pictures from a Make scenario, and start a scenario when Mutator has made something. > **Nothing here publishes anything.** No module reaches a social account, > because there is no endpoint in Mutator's API that schedules a post. A post > exists once a person has put a particular file in a particular account's > schedule, in Mutator, with their name against the decision. ## Where it stands The Mutator app is written and is not published yet. Everything below works today with Make's own **Custom webhook** and **HTTP** modules, against the same API the app uses. ## Connecting Settings → API keys → **New key** in Mutator. Keys come with any paid plan and the secret is shown once. The connection is labelled with the workspace's name, so two workspaces connected at once are told apart. A **read** key is enough for the triggers and searches. The three actions that make things need a **write** key. Every paid plan can list its formats and run one. A format that is not switched on (every format on Pro) is made from its latest draft as a dry run, the way Make does: it makes and publishes nothing. ## Instant triggers Four, each attached as a webhook when you switch the scenario on and detached when you switch it off: a video or picture was made, an automation run finished, a generation finished, or a post somebody scheduled went out. The first three read the thing the delivery names rather than passing the delivery on. A delivery carries ids and no links, because it can sit on a queue while a receiver is down, and a signed link that old is a broken scenario. **Watch posts published** is the exception, and needs no fetch: a post is already fully described by its delivery, so the fields arrive as they are. Nothing in this app publishes anything; this reports what a person decided in Mutator. `GET /api/v1/posts` has the same rows if you want to read back over them. ### Doing it today, without the app Add a **Webhooks → Custom webhook** module, copy its URL, and subscribe it once: ```bash curl -X POST https://mutator.app/api/v1/hooks \ -H "Authorization: Bearer loop_sk_..." \ -H "content-type: application/json" \ -d '{"event": "media.created", "targetUrl": "YOUR_CUSTOM_WEBHOOK_URL"}' ``` The events are `media.created`, `run.finished`, `generation.finished` and `post.published`. The reply carries a `secret`, and every delivery is signed with it; [the API documentation](https://mutator.app/docs/api) has the check to run before trusting one. `DELETE /api/v1/hooks/{id}` stops it. Follow the webhook with an **HTTP → Make a request** on `/api/v1/media/{id}` to read the file the delivery names. ## Modules **Ask for a video or picture.** It answers at once with an id, because a clip takes minutes and a module that waits is one that times out. Use the generation trigger, or a Sleep and then a search. **Start an automation.** Runs one you have already built and switched on. **Add a picture from a URL.** So a generation can paint from it. PNG, JPEG, WebP or GIF, up to 32 MB, from a public link. It answers `status: checking`, because everything arriving goes past the content check first. **List videos and pictures**, and **list automations**, for the searches. ### Doing it today, without the app **HTTP → Make a request**, with your key in the Authorization header. Every endpoint is in [the API documentation](https://mutator.app/docs/api). ## The idempotency key, which every action asks for Every write in Mutator's API takes one, because a request that timed out after the work started looks exactly like one that timed out before it, and the work is a video somebody paid for. Zapier and n8n each hand an integration something stable to build a key from. Make does not expose a per-execution id the app can rely on, and a key that changed on every retry would be the same as having none, so the app asks for one instead. **Map something that identifies the thing that started the scenario**: the row id, the file id, the record id. Mutator answers a repeat of the same key with the first answer rather than making a second video. ## Limits 120 reads and 30 writes a minute, per key. Download links last six hours: fetch the bytes rather than storing the link.