Control API reference
The local HTTP API that SimpliGen exposes on your machine, for building custom integrations and running generations headless.
SimpliGen runs a small HTTP Control API on your machine while the app is open. It is the same API the MCP server talks to, exposed directly so you can drive SimpliGen from your own scripts, services, or agents, including with the app minimized to the tray.
MCP vs the Control API
If you just want an AI agent (Claude, Codex, etc.) to use SimpliGen, set up the MCP server instead, it wraps this API. Use the Control API directly when you are writing a custom integration in any language.
Base URL and discovery
The server binds to loopback only and defaults to:
http://127.0.0.1:48199If that port is taken, SimpliGen falls back to an OS-assigned port. The current port (and API version) is always written to a discovery file in the app's user data directory:
# Windows
%APPDATA%\simpligen\agent-api.json
# macOS
~/Library/Application Support/simpligen/agent-api.json
# Linux
~/.config/simpligen/agent-api.json{ "port": 48199, "version": 1 }Read this file to find the live port rather than hardcoding 48199.
Requirements
The Control API only runs when the app is open and the machine is entitled to
agent access (it is part of your license). Requests are only served when the
Host header is a loopback alias; anything else gets 403 forbidden_host (a
DNS-rebinding guard).
Authentication
Every endpoint except GET /health requires a pairing token in the
Authorization header:
Authorization: Bearer sg-agent-xxxxxxxx...Create a token in the app under Settings → Connect an agent (see Connect an agent). The raw token is shown once; only a hash is stored. Each token can carry an optional spend cap for cloud generations. Revoking a token in the app takes effect immediately.
A missing or invalid token returns 401 unauthorized.
Conventions
- Requests: JSON bodies (
Content-Type: application/json), up to 50 MB. - Responses: always JSON.
- Errors: non-2xx responses have the shape
{ "errorCode": "...", "message": "..." }. - IDs: presets are addressed by
presetId(+ optionalpackId); jobs byjobId.
A minimal authenticated call:
TOKEN="sg-agent-..."
curl -s http://127.0.0.1:48199/status -H "Authorization: Bearer $TOKEN"Health
GET /health
Liveness probe. No authentication.
{ "ok": true, "name": "simpligen", "version": 1 }Capabilities
GET /capabilities
Lists every preset you can generate with, one entry per (preset, mediaType),
with its inputs, options, local readiness, and cloud eligibility/cost.
{
"capabilities": [
{
"packId": "local-core-pack",
"presetId": "z-image-turbo",
"mediaType": "image",
"name": "Z-Image Turbo",
"description": "Ultra-fast, high-quality images in seconds",
"apiProvider": null,
"inputs": {
"acceptsPrompt": true,
"requiresPrompt": true,
"requiresImage": false,
"acceptsReferenceImages": null,
"acceptsVideo": false,
"acceptsAudio": false,
"acceptsReferenceVideos": false,
"acceptsReferenceAudios": false,
"supportsLoras": true
},
"options": {
"steps": null,
"cfg": null,
"duration": null,
"resolution": null,
"aspectRatio": ["1:1", "3:4", "4:3", "9:16", "16:9"]
},
"cloudAliasTo": null,
"localReady": true,
"cloudEligible": true,
"cloudCost": 4
}
]
}| Field | Meaning |
|---|---|
localReady | All model files are installed; the preset can run locally now. |
cloudEligible | The preset can run on SimpliGen Cloud. |
cloudCost | Approximate credit cost for a cloud run (null if not eligible). |
apiProvider | Non-null for external-API presets (e.g. OpenAI, Google) which the Control API cannot dispatch. |
inputs / options | What the preset accepts and the option ranges/aspect ratios you may pass to POST /generate. steps/cfg are non-null only for presets that expose advanced controls. |
promptFamily | The prompting guide family for this preset (for example z-image), or null when none is declared. GET /prompt-guide/:presetId returns the guide. |
inputs.requiresPrompt | false for presets whose instructions are built in (cutouts, head or outfit swaps, upscales): an empty prompt is valid, and any text you send is added as extra notes. |
Status
GET /status
{
"engineRunning": true,
"engineVersion": "v0.26.0",
"cloudConnected": true,
"creditBalance": 1280,
"activeProject": { "id": "default", "name": "My Project" },
"characters": { "enabled": true, "base": 1, "edit": 1, "i2v": 1, "enhancePresets": 2 }
}POST /engine/start
Starts the local engine when it is not already running. Takes no body.
{ "engineRunning": true, "engineVersion": "v0.37.0", "engineStarting": false }A cold boot takes longer than the request waits. When that happens the reply is
202 with engineStarting: true and the engine keeps booting, so poll
GET /status until engineRunning is true.
Starting an engine that already runs returns 200 with alreadyRunning: true
and changes nothing. If no engine is installed the reply is 409 engine_not_installed: installing one is a multi gigabyte download and stays a
job for the app, not the API. A machine the engine refuses to run on answers
409 engine_unsupported_hardware, which retrying will not fix.
POST /engine/stop
Stops the local engine and frees its VRAM. Cloud generation is unaffected. Stopping an engine that is already down is a success.
POST /engine/restart
Stops and starts it again, for a wedged engine or after a custom node was
installed or removed. Same 202 behaviour as start when the boot outlasts the
request.
Projects
Projects are the containers that hold generations.
GET /projects
{ "projects": [ { "id": "default", "name": "My Project", "slug": "my-project" } ] }POST /projects
{ "name": "Campaign assets" }Returns 201 with { "project": { ... } }. name is required (400 invalid_options otherwise).
Uploads
Inputs (source images, reference images, source videos) are never passed as
local paths. Upload the bytes first and pass the returned handle to
POST /generate.
POST /uploads
{ "filename": "ref.png", "dataBase64": "<base64 of the file bytes>" }{ "handle": "a1b2c3d4.png", "sizeBytes": 51234 }Max upload is 100 MB (413 asset_too_large). Unsupported extensions return
400 unsupported_file_type.
Generate
POST /generate
Enqueues a generation and returns immediately with a jobId. Poll
GET /jobs/:id (see Jobs) for the result.
{
"presetId": "z-image-turbo",
"packId": "local-core-pack",
"mediaType": "image",
"prompt": "a red fox in the snow, cinematic",
"backend": "auto",
"project": "default",
"image": "a1b2c3d4.png",
"referenceImages": [{ "handle": "e5f6.png", "label": "style" }],
"loras": [
{ "name": "RoomLora", "weight": 0.85 },
{ "name": "Mia", "weight": 0.75 }
],
"options": { "aspectRatio": "16:9", "steps": 8, "negativePrompt": "blurry, watermark" }
}| Field | Required | Notes |
|---|---|---|
presetId | yes | From GET /capabilities. |
mediaType | yes | "image" or "video". |
packId | no | If given, must match the preset's pack. |
prompt | conditional | Required unless the preset reports inputs.requiresPrompt: false. Those presets have their instructions built in: send "" to run them as they are, or add text to pass extra notes. |
backend | no | "auto" (default), "local", or "cloud". Auto prefers local if installed, else cloud if connected. |
image | conditional | Upload handle. Required for image-to-X presets (inputs.requiresImage). |
video | conditional | Upload handle, for video-input presets. |
referenceImages | conditional | Array of handles or { handle, label }, for reference-image presets. |
audio | conditional | Upload handle, for audio-driven presets (lip-sync, audio-to-video). Required when the preset reports acceptsAudio, otherwise the job is rejected with needs_audio. |
audioStartSeconds | no | Where in the track to start, in seconds. Also accepted under options. |
referenceVideos | conditional | Array of handles or { handle, soundtrackMode, durationSeconds }, for reference-clip presets. |
referenceAudios | conditional | Array of handles or { handle, durationSeconds }, for reference-sound presets. |
loras | no | Array of { name, weight } to apply, for presets where inputs.supportsLoras is true. name matches a LoRA's display name (or filename) from GET /loras; weight defaults to the LoRA's default strength. Unknown names return 400 unknown_lora. Local backend only for now. |
project | no | Project id or slug. Defaults to the active project. |
options.aspectRatio | no | Must be one of the preset's supported ratios. |
options.steps / options.cfg | no | Within the preset's advertised ranges. |
options.negativePrompt | no | Extra negative terms (the UI's Advanced Options negative). Also accepted at the top level as negativePrompt. Additive: the preset's default negative is always applied and your terms are appended after it (, ). Send nothing to use the preset default. On SimpliGen Cloud runs extra terms are not sent, and the preset's default negative is used as is. |
options.durationSeconds | no | Video duration. |
options.resolution | no | Resolution tier label. |
{ "jobId": "gen_abc123", "backend": "local", "status": "queued" }For a cloud job the response also includes "cloudCost": <credits>.
Backends
External-API presets (where apiProvider is non-null, e.g. GPT Image 2, Veo)
cannot be dispatched here, you will get 400 unsupported_backend. Use a local
or SimpliGen Cloud preset. Cloud requires a connected account
(400 cloud_not_connected); local requires the models installed
(400 preset_not_installed, prepare them first).
GET /loras
Lists the user's installed LoRAs so agents can discover what to pass in
POST /generate's loras array. Match on name (the display name).
{
"loras": [
{ "name": "RoomLora", "filename": "RoomLora_000002400.safetensors", "baseModel": "flux", "triggerWords": "a room", "defaultStrength": 0.9 },
{ "name": "Mia", "filename": "mia_v2.safetensors", "baseModel": "flux", "triggerWords": "mia", "defaultStrength": 1.0 }
]
}Pass the LoRA where it fits the preset's base model (baseModel), and use its
own trigger words in your prompt so it activates.
Prompt guides
GET /prompt-guide/:presetId
Returns the prompting guide for a preset: how its model reads a prompt, the
structure and length it likes, camera and motion wording, negatives, how to
word characters, and worked examples. Read it before writing the prompt for
POST /generate. Guides are served by SimpliGen and cached, so they also work
offline from the last copy of each guide that was opened before.
{
"presetId": "z-image-turbo",
"family": "z-image",
"familyName": "Z-Image",
"version": 1,
"generic": false,
"text": "# Z-Image prompting guide ...\n\n## Negative prompts in SimpliGen\n...\n\n## Notes for this preset\n\n..."
}| Field | Meaning |
|---|---|
family / familyName | The guide family the preset resolves to, and its display name (see promptFamily on GET /capabilities). |
version | The version of this family's guide. |
generic | true when no model-specific guide exists for this preset. The text then says so plainly and gives only general advice. |
text | The family guide in Markdown, followed by a ## Negative prompts in SimpliGen section that every guide shares. When the preset has notes of its own, a ## Notes for this preset section is appended. |
| HTTP | errorCode | When |
|---|---|---|
| 404 | unknown_preset | No preset with that id. |
| 503 | guides_unavailable | The guides could not be loaded and no cached copy exists: "Prompt guides could not be loaded. Check the internet connection and try again." |
Jobs
GET /jobs/:id
{
"job": {
"jobId": "gen_abc123",
"status": "completed",
"backend": "local",
"mediaType": "image",
"presetId": "z-image-turbo",
"projectId": "default",
"resultUrl": "local-file:///C:/Users/you/.../output.png",
"resultPath": "C:/Users/you/.../output.png",
"errorMessage": null,
"createdAt": "2026-06-25T20:00:00.000Z",
"completedAt": "2026-06-25T20:00:08.000Z"
}
}status is one of queued, processing, completed, failed. On success,
resultPath is the decoded local file path; on failure, errorMessage explains
why. Unknown id returns 404 job_not_found.
GET /jobs
Returns the 25 most recent jobs as { "jobs": [ ...job... ] }.
POST /jobs/:id/cancel
Cancels a queued/running job. Returns { "jobId", "status", "note?" }. A cloud
job already in flight may still complete on the server (noted in note).
Preparing presets
Local presets download their model files on first use. You can prepare them ahead of time.
POST /presets/prepare
{ "presetId": "z-image-turbo", "mediaType": "image" }Returns 200 { "status": "already_installed" } or 202 { "status": "started" }
(download runs in the background).
GET /presets/prepare
Returns in-flight downloads: { "downloads": [ ... ] }. Poll this, or
GET /capabilities (watch localReady), until the preset is ready.
Characters
Character Studio is scriptable too. Characters are local-only (cloud is rejected). See Characters for the concepts.
| Endpoint | Purpose |
|---|---|
GET /characters | List characters. |
GET /characters/:id | Get one character. |
GET /character-presets | Installed character presets (base / edit / i2v). |
GET /character-features | Available identity/appearance feature options. |
POST /characters | Create a character (mode: "generate" from a description, or "upload" from an uploaded base image handle). |
POST /characters/:id/generate-image | Generate an image of the character ({ prompt, aspect?, count?, presetId? }). |
POST /characters/:id/generate-frame | Generate a single video start frame. |
POST /characters/:id/animate | Animate the character into a video. |
POST /characters/:id/preset | Set the character's default preset. |
POST /characters/:id/enhance | Upscale or enhance a character asset. Name the source with exactly one of target: "base", jobId (a previous generation), or handle (from POST /uploads). Optional: mediaType, presetId, scale, keepResolution, applyAsBase. |
All generation endpoints return a jobId you poll via GET /jobs/:id, exactly
like POST /generate.
Recipes
Author multi-step recipes (flows) out of packs and presets. Discover the building blocks, compose from a high-level spec, then optionally publish.
| Endpoint | Purpose |
|---|---|
GET /recipe-blocks | Entry points + every preset usable as a step, with its port contract. |
POST /recipes/validate | Dry-run a spec (compose+validate) or a config, no save. Returns validation. |
POST /recipes | Compose from a spec and save a draft. Returns { recipeId, valid, errors, config }. |
GET /recipes | List every recipe on this device (installed from the Store or authored here) with its inputs (key, kind, label, required, options) and step count. |
GET /recipes/:id | Get one recipe, its full config, its inputs, and readiness (per step: which preset it runs and whether it is installed locally). |
POST /recipes/:id/run | Run a recipe. Body: { inputs: { key: value }, engine?: "local" | "cloud" | "auto" }. Subject inputs take a character or product id, image/video/audio inputs take upload handles, everything else takes plain values. Returns { runId, status: "running", engine, steps, cloudCost? }. Cloud runs are priced up front and count against the token's spend cap. |
GET /recipes/:id/runs | Recent runs of a recipe, newest first. |
GET /recipe-runs/:id | Status of a run: status (running, completed, failed, cancelled), steps (each with status, job id and result path) and resultPath once the last media step completes. |
POST /recipe-runs/:id/cancel | Stop a run. No further steps start; a local in-flight step is interrupted. |
POST /recipes/:id/publish | Submit to the Creator Marketplace (needs a connected account). |
Products and subjects
A recipe's subject input takes either a character or a product. Products are the other subject type: a named thing with a base photo, used by Product Studio and UGC recipes.
| Endpoint | Purpose |
|---|---|
GET /subjects | Every subject a recipe can take: characters and products (id, type, name, status). |
GET /products | List products. |
GET /products/:id | Get one product. |
POST /products | Create a product from an uploaded photo: { name, image: <upload handle>, description?, category? }. The description and category are woven into recipe prompts. Returns { productId, product }. |
DELETE /products/:id | Remove a product and its photo. |
A spec is a high-level description; the app auto-wires ports, the step-to-step
media chain, and a prompt input per step, then validates.
{
"spec": {
"name": "Product to spinning video",
"category": "product",
"steps": [
{ "preset": "krea2-pack:krea2-turbo" },
{ "preset": "wan-2.2-video-pack:wan-2.2-i2v" }
]
}
}{
"recipeId": "b1f2…",
"valid": true,
"errors": [],
"config": { "blocks": [ "…" ], "inputs": [ "…" ], "output": { "from": "n2.video" } }
}Composing and saving needs no creator account. POST /recipes/:id/publish
requires a connected account and returns not_connected (with needsConnect: true)
until the user connects one in the app; the same draft then publishes unchanged.
Every preset the recipe uses must belong to a distributable first-party pack.
Video Studio
Films are Video Studio documents: clips (shots to render) on a timeline over audio tracks. Every editing route answers { change, timeline }: what changed, and the film's timeline after it (the same shape GET /films/:id answers). With the film open in the studio the change shows live and undoes with Ctrl+Z; a closed film is edited and saved.
Clips are named in the path by id, by name, or by number (their order on the timeline, earliest first). Times are seconds, or bars once the film has a tempo: a position is 3, { "seconds": 3 } or { "bar": 5 } (bar 1 is the first downbeat), a length is 5, { "seconds": 5 } or { "bars": 2 }.
| Route | What it does |
|---|---|
GET /films?projectId= | Films of a project (default: the current one). |
POST /films | Create a film: name, optional projectId, aspect (16:9, 9:16, 1:1), quality (480p, 720p, 1080p), renderOn (local, cloud), presets ({ t2v, i2v, r2v }). Answers the timeline. |
GET /films/:id | The timeline. |
PATCH /films/:id | Film settings: aspect, quality, hires2x, renderOn, presets, subject, cast (names or ids), location (name, id or null). |
POST /films/:id/tracks | Add a track: upload (a handle from POST /uploads), kind (singing, music, dialogue, effects), name, at. |
PATCH /films/:id/tracks/:track | at, muted, volume (0 to 1). |
POST /films/:id/tempo | Detect the tempo from the longest unmuted track, or track. Answers { bpm, offset, track, timeline }. |
POST /films/:id/clips | Add a clip: at, length, name, lane. |
PATCH /films/:id/clips/:clip | shots (each { prompt, seconds, line, speaker, framing }), cast, location ("none" drops the film's, "film" inherits it again), link (fresh, continue, motion; none and frame are also accepted, as fresh and continue), hears ([{ track, on }]), preset. |
POST /films/:id/clips/:clip/move | at, lane. |
POST /films/:id/clips/:clip/trim | length, trimIn (where the clip starts inside its take; 0 is the start of the take). |
DELETE /films/:id/clips/:clip | Remove the clip. |
POST /films/:id/plan | Lay out a draft (below). |
POST /films/:id/fades | target (a clip or a track), fadeIn, fadeOut. |
POST /films/:id/markers | at, name. |
POST /films/:id/export-range | from and to (both needed), or clear: true. |
POST /films/:id/renders | Render clips ("stale", the default, or a list). Answers 202 with { started, held, credits } at once, or 200 with a note when nothing needs a render. |
GET /films/:id/clips/:clip/takes | The clip's takes, newest first. |
POST /films/:id/clips/:clip/take | generationId: make that take the one the clip shows. |
POST /films/:id/export | Stitch the film. Answers 201 with { path, resultUrl, blanks }. |
Laying out a draft
POST /films/:id/plan
{
"mode": "append",
"cast": ["Kai", "Mira"],
"location": "Rooftop",
"clips": [
{ "bars": 2, "shots": [{ "prompt": "@Kai looks over the city", "framing": "wide" }] },
{ "bars": 2, "link": "continue",
"shots": [{ "prompt": "@Kai sings to camera", "line": "Neon rain", "speaker": "Kai", "framing": "close-up" }] },
{ "bars": 3, "location": "Neon Street", "cast": ["Mira"],
"shots": [{ "prompt": "@Mira walks through the rain", "seconds": 4 }, { "prompt": "she turns", "seconds": 3 }] }
]
}The plan is checked as a whole first. A plan with problems answers 400 invalid_plan with every problem in details.problems (each prefixed with its clip, such as clip 2: ...) and changes nothing. mode: "replace" replaces every clip and is refused when clips have takes unless "discardTakes": true.
Renders and the spend cap
A cloud render is priced before anything is queued and counts against the token's spend cap: over the cap answers 402 exceeds_spend_cap, an unknown price 503 cost_unknown. Clips that continue from the clip before render one at a time, each after the take it starts from; the call answers at once, and GET /films/:id shows each clip's take: none, rendering, current, edited, longer, unknown (it cannot be told yet) or imported (a video brought in, never rendered).
Locations
| Route | What it does |
|---|---|
GET /locations | The Library's locations: id, name, description, hasPicture. |
POST /locations | Create a location from its picture: name, description, and upload (a handle) or fromJob (a finished image job). |
Spend caps
When a token has a spend cap, cloud generations are priced before dispatch and
rejected if they would exceed the remaining allowance
(402 exceeds_spend_cap). If the cost can't be determined (cloud status
unavailable), a capped token fails closed with 503 cost_unknown. Local
generations are always free of credit cost.
A complete flow
BASE=http://127.0.0.1:48199
AUTH="Authorization: Bearer sg-agent-..."
# 1. Pick a ready preset
curl -s $BASE/capabilities -H "$AUTH"
# 2. Generate
JOB=$(curl -s $BASE/generate -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"presetId":"z-image-turbo","mediaType":"image","prompt":"a red fox in the snow","options":{"aspectRatio":"16:9"}}' \
| jq -r .jobId)
# 3. Poll until done, then read resultPath
curl -s $BASE/jobs/$JOB -H "$AUTH" | jq '.job | {status, resultPath, errorMessage}'Error codes
| HTTP | errorCode | When |
|---|---|---|
| 400 | invalid_json | Body is not valid JSON. |
| 400 | invalid_options | A required field is missing or malformed. |
| 400 | invalid_preset | Preset/pack/mediaType mismatch. |
| 400 | needs_* | A required input (prompt, image, references) is missing. |
| 400 | invalid_handle | An input was not a valid upload handle. |
| 400 | unsupported_backend | Preset runs on an external API. |
| 400 | preset_not_installed | Local backend chosen but models aren't installed. |
| 400 | cloud_not_connected | Cloud backend chosen but no account is connected. |
| 400 | invalid_plan | A film plan has problems; details.problems lists them all. |
| 400 | no_tempo | A time in bars before the film has a tempo; detect the tempo first. |
| 400 | no_audio | Tempo asked of a film with no audio track. |
| 400 | no_pulse | No steady beat could be heard in the track. |
| 401 | unauthorized | Missing or invalid token. |
| 402 | exceeds_spend_cap | Cloud cost exceeds the token's spend cap. |
| 402 | exceeds_balance | The cloud balance does not cover the renders. |
| 403 | forbidden_host | Non-loopback Host header. |
| 403 | studio_not_installed | The Video Studio is not installed on this computer. |
| 404 | not_found / invalid_preset / job_not_found | Unknown route, preset, or job. |
| 404 | unknown_preset | GET /prompt-guide/:presetId with an id that is not a preset. |
| 404 | film_not_found | No film with that id. |
| 404 | clip_not_found | No clip by that id, name or number. |
| 404 | track_not_found | No track by that name or id. |
| 404 | cast_not_found | No character by that name or id in the Library. |
| 404 | location_not_found | No location by that name or id, or it has no picture yet. |
| 409 | clips_not_ready | Export asked while clips still need a render; details.clips names them. |
| 413 | asset_too_large | Upload over 100 MB. |
| 503 | guides_unavailable | The prompt guides could not be loaded and none are cached. |
| 503 | cost_unknown | Capped token; cloud cost couldn't be determined. |
| 503 | studio_busy | The SimpliGen window could not be reached or did not answer in time. If it timed out, the change may still have been made: read the film before trying again. |
| 500 | internal_error | Unexpected server error. |