Integration

How machines join the fleet, and how scripts and agents drive the queue and galleries.

API keys

Executor lifecycle

An executor enrols itself with a one-time token, lands in PENDING, and stays inert until an operator approves it. After approval it loops: pull a job, run it, heartbeat while running, submit the result.

  1. 1Operator mints an enrollment token (Executors → Add node)
  2. 2Worker POSTs /executors/register → receives its bearer token, status PENDING
  3. 3Operator approves the node
  4. 4Worker polls /pull → claims one job at a time
  5. 5Worker heartbeats to extend the lease and report progress
  6. 6Worker submits result + files; job becomes SUCCEEDED or FAILED

Executor authentication

Every call below carries the bearer token issued at registration: Authorization: Bearer ufx_…. The plaintext is returned exactly once — only its SHA-256 hash is stored, so a lost token means re-enrolling.

A node that is not APPROVED gets 403 with a code field naming its state. That is expected while waiting for approval — keep polling rather than exiting.

Executor tokens work only on these endpoints; an API key here is refused with 401.

Executor endpoints

POST/api/integration/executors/registerauth: enrollment token in body
{
  "enrollmentToken": "ufe_…",
  "name": "studio-mac",
  "hostname": "timmys-mbp.local",
  "backend": "MLX_SWIFT",
  "capabilities": ["T2I", "EDIT", "INTERLEAVE", "VQA"],
  "features": ["think", "distill-8step"],
  "maxPixels": 4194304,
  "concurrency": 1,
  "specs": { "chip": "Apple M5 Pro", "memoryBytes": 68719476736, "quantization": "8bit-g64" }
}
201 { "executorId": "…", "token": "ufx_…", "status": "PENDING" }

The enrollment token is burned atomically, so two machines racing the same token cannot both enrol. `features` is optional and defaults to none; see Features below.

POST/api/integration/pullauth: executor token
{ "capabilities": ["T2I","VQA"], "features": ["think"], "maxPixels": 4194304 }   // all optional
200 {
  "jobId": "…", "kind": "T2I", "params": { … },
  "inputs": [{ "id": "…", "url": "/api/integration/assets/…", "filename": "a.png", "mimeType": "image/png" }],
  "leaseUntil": "2026-08-27T…Z", "attempt": 1
}
204   // nothing this node can run

Capabilities and features re-advertised here take effect immediately — lower maxPixels after an OOM, or point a node at distilled weights, without re-enrolling. Claiming is atomic; two executors pulling at once never get the same job.

POST/api/integration/jobs/:jobId/heartbeatauth: executor token
{ "progress": 0.42, "message": "step 21/50" }   // both optional
200 { "leaseUntil": "…", "canceled": false }

Extends the lease by 1200s. This is also the only way to learn about cancellation — when "canceled" is true, abort and stop; do not submit.

POST/api/integration/jobs/:jobId/submitauth: executor token
multipart/form-data
  payload = {"status":"SUCCEEDED","text":"…","metrics":{"durationMs":332000,"peakMemoryBytes":24030000000}}
  files   = <one or more binary parts, image/png · image/jpeg · image/webp>
200 { "ok": true, "assets": 1 }

Terminal — a job accepts exactly one submit. The house worker submits JPEG at quality 60: a render is photographic, and lossless costs about ten times the bytes for nothing visible. Image kinds attach files; VQA sends text only. A file of any other type is refused with 415 before anything is stored. If the lease was reclaimed while you were working, this returns 409 and the result is discarded. A job queued from a gallery has its outputs appended there in the same transaction.

GET/api/integration/assets/:assetIdauth: executor token or API key
200 binary

With an executor token, scoped to INPUT assets. With an API key, any asset; add `?download=1` for an attachment.

Leases and failure

A claimed job carries a lease of 1200s. Heartbeat to extend it — a 2048² render measured 332s on MLX 8-bit and 623s on PyTorch/MPS, so a long job outlives its initial lease without one.

If the lease expires, the platform reclaims the job on the next pull from any node and re-queues it. After maxAttempts it is failed outright rather than looped forever — a machine that OOMs on a job will OOM on the retry too.

Crashing mid-run is therefore safe: do nothing, and the job returns to the queue. What you must not do is submit and then crash — submit is the commit point.

Features

capabilities says which scenarios a node runs. Features say which variants of them it can accept, and exist because a job can need something the hardware or the loaded weights do not have. The dispatcher will not hand a node a job whose required features it has not advertised.

FeatureRequired byWhy a node might lack it
thinkT2I / VQA with `think: true`The reasoning pass. Measured at 53 GB peak against 22 GB without it, so a small node should not advertise it.
distill-8stepT2I with `preset: "fast"`An 8-step distilled artifact, loaded from its own directory. 34 s against 379 s for the same prompt and seed.

A node that advertises nothing still receives ordinary work. Omitting features entirely on an executor that enrolled before they existed is read as ["think"], which is what those nodes were already doing; one pull with an explicit list replaces that.

Job kinds

The same four kinds and the same params whichever door queues them — the client API below validates against these schemas too.

KindParamsSubmits
T2Iprompt, width, height, think, preset, steps, cfgScale, timestepShift, seedone image
EDITprompt, inputAssetIds[], imgCfgScale, + samplingone image
INTERLEAVEprompt, resolution, + samplingseveral images + text
VQAquestion, inputAssetIds[], maxNewTokens, temperature, topP, thinktext only

Client API

Everything an operator does in this console — queue a render, check on it, download the result, curate and share galleries — done by a script or an agent instead. It lives under /api/integration/ beside the executor endpoints and takes a different credential: an API key, minted in API keys above and sent on every call as Authorization: Bearer ufk_….

Keys have no scopes; one key does everything listed here. Revoke it above when the thing using it is retired — from the next request it gets 403.

Errors are JSON, { "error", "code", "detail" }. 401: no key, an unknown key, or an executor token. 403 with code: "REVOKED": the key was revoked — stop retrying. 404: unknown gallery, item, job or asset. 409: a state conflict — a stale reorder, a job with nothing to add yet, a full gallery. 400: a body that failed validation; detail carries the field errors.

  1. 1Upload any input images → POST /assets
  2. 2Queue a job → POST /jobs, or POST /galleries/:id/jobs to render straight into a gallery
  3. 3Poll GET /jobs/:id until the status is SUCCEEDED or FAILED
  4. 4Download each output from its url, or arrange the pages and share the gallery link

Galleries

A gallery is an ordered set of rendered images: pages, each backed by one output asset. position is 0-based and items always come back in that order. An image can be a page in several galleries at once, and removing a page never touches the image or its job.

A job queued through POST /galleries/:id/jobs carries the gallery's id. When it succeeds its outputs are appended at the end, in the same transaction as the status change. Until then it is listed under jobs in the gallery detail; a job that fails or is cancelled stays there until dismissed. Ask (VQA) jobs render no image and are refused for a gallery with 400.

Finished renders can also be added by hand: { "jobId" } adds every output of a job, { "assetId" } adds one. Pages already present are skipped rather than refused, so added may be 0. A gallery holds at most 500 pages, counting renders still in flight.

Reordering takes the complete order — every current item id exactly once. If a render landed a new page between your read and your write, the set no longer matches and the call answers 409 with code: "STALE"; read the gallery again and retry with the full list.

Sharing is off by default. Enabling it mints a link once and shareUrl carries it; disabling keeps the token, so the same link works again when re-enabled; rotating replaces it and the old link stops working at once. The share page is read-only and shows the title, the description and the images — no prompts, jobs or machines. shareUrl is absolute when the deployment has NEXTAUTH_URL set, otherwise a path to prefix with the origin you are calling.

Client endpoints

GET/api/integration/galleries?page=1&limit=20auth: API key
200 { "galleries": [ {
    "id": "…", "title": "Spring lookbook", "description": null,
    "shareEnabled": false, "shareUrl": null,
    "itemCount": 12, "pendingCount": 2,
    "coverUrl": "/api/integration/assets/…",
    "createdAt": "2026-08-31T…Z", "updatedAt": "2026-08-31T…Z"
  } ], "total": 3, "page": 1, "limit": 20 }

Most recently changed first. `limit` is capped at 100.

POST/api/integration/galleriesauth: API key
{ "title": "Spring lookbook", "description": "Shown on the share page." }   // description optional
201 { "gallery": {
    "id": "…", "title": "Spring lookbook", "description": null,
    "shareEnabled": true, "shareUrl": "https://kilndeck.example/s/…",
    "createdAt": "…", "updatedAt": "…",
    "items": [{
      "id": "…", "position": 0, "jobId": "…", "assetId": "…",
      "url": "/api/integration/assets/…",
      "width": 2048, "height": 2048, "bytes": 4194304, "filename": "….png"
    }],
    "jobs": [{
      "id": "…", "kind": "T2I", "status": "PROCESSING", "params": { … },
      "progress": 0.42, "progressNote": "step 21/50", "error": null,
      "attemptCount": 1, "maxAttempts": 3,
      "createdAt": "…", "startedAt": "…", "finishedAt": null
    }]
  } }
GET/api/integration/galleries/:galleryIdauth: API key
200 { "gallery": { … } }   // the detail shape above
PATCH/api/integration/galleries/:galleryIdauth: API key
{ "title": "…", "description": null, "shareEnabled": true }   // any subset, at least one field
200 { "gallery": { … } }

`description: null` clears it. `shareEnabled: true` mints the link on first use and fills `shareUrl`.

DELETE/api/integration/galleries/:galleryIdauth: API key
200 { "ok": true }

The gallery, its order and its share link are removed. The images and their jobs stay.

POST/api/integration/galleries/:galleryId/itemsauth: API key
{ "jobId": "…" }      // every output of the job
{ "assetId": "…" }    // one output
200 { "gallery": { … }, "added": 1 }
409 { "error": "This job is still rendering", "code": "NO_OUTPUTS" }

Only OUTPUT assets can be pages. A job without outputs answers 409 — still rendering, or finished with nothing to show.

PUT/api/integration/galleries/:galleryId/items/orderauth: API key
{ "itemIds": ["…", "…", "…"] }   // every current item id, exactly once
200 { "gallery": { … } }
409 { "error": "The gallery changed while you were arranging it. …", "code": "STALE" }
DELETE/api/integration/galleries/:galleryId/items/:itemIdauth: API key
200 { "gallery": { … } }

Removes the page. The image stays on its job.

DELETE/api/integration/galleries/:galleryId/jobs/:jobIdauth: API key
200 { "gallery": { … } }
409 { "code": "JOB_LIVE" }       // still queued or running — it has to finish, or be cancelled from the console
409 { "code": "JOB_FINISHED" }   // already succeeded — its renders are pages; remove those instead

Dismisses a failed or cancelled job's tile. A succeeded job is represented by its pages, so it is never dismissable — do not retry on JOB_FINISHED.

POST/api/integration/galleries/:galleryId/share/rotateauth: API key
200 { "gallery": { … } }   // shareUrl now carries the new link

The old link stops working immediately. Whether sharing is on or off does not change.

POST/api/integration/galleries/:galleryId/jobsauth: API key
{ "kind": "T2I", "params": { "prompt": "…", "width": 2048, "height": 2048 }, "priority": 0 }   // priority optional
201 { "job": { …, "galleryId": "…", "status": "PENDING", "outputs": [] } }

Same body as POST /jobs without `galleryId` — the path decides. Params follow the kind's schema in Job kinds above. VQA answers 400; a full gallery answers 409.

POST/api/integration/jobsauth: API key
{
  "kind": "EDIT",
  "params": { "prompt": "…", "inputAssetIds": ["…"] },
  "priority": 0,          // optional, -1000..1000, higher runs first
  "galleryId": "…"        // optional
}
201 { "job": { …, "status": "PENDING", "outputs": [] } }

Every id in `inputAssetIds` must be an asset you uploaded and have not used yet: an asset attaches to one job only, and reusing one answers 409.

GET/api/integration/jobs/:jobIdauth: API key
200 { "job": {
    "id": "…", "kind": "T2I", "status": "SUCCEEDED",
    "progress": 1, "progressNote": null, "error": null, "resultText": null,
    "galleryId": "…", "params": { … },
    "attemptCount": 1, "maxAttempts": 3,
    "createdAt": "…", "startedAt": "…", "finishedAt": "…",
    "outputs": [{
      "assetId": "…", "url": "/api/integration/assets/…",
      "width": 2048, "height": 2048, "bytes": 4194304, "filename": "….png"
    }]
  } }

`outputs` is empty until SUCCEEDED. `resultText` carries VQA answers and interleaved narration; image jobs leave it null.

POST/api/integration/assetsauth: API key
multipart/form-data
  file = <image/png · image/jpeg · image/webp, up to 32 MB>
201 { "asset": { "id": "…", "filename": "a.png", "mimeType": "image/png", "width": 1024, "height": 1024, "bytes": 812345 } }

Uploads land detached. Queue a job with the id in `inputAssetIds` to attach one; it then belongs to that job.

GET/api/integration/assets/:assetIdauth: API key
200 binary

Any asset, input or output — the `url` on every item and output points here. Add `?download=1` for an attachment with a descriptive filename. Responses are immutable-cacheable: the same id is always the same bytes.

Polling a job

PENDING → PROCESSING → SUCCEEDED | FAILED, plus CANCELED when an operator cancels from the console. A job can also go from PROCESSING back to PENDING: the machine running it went quiet, its lease expired, and the job was re-queued. That is a retry, not a failure — keep polling. After maxAttempts such retries it fails outright.

outputs stays empty until the status is SUCCEEDED; the files and the status land in one transaction, so a succeeded job never has outputs missing. progress (0..1) and progressNote come from the executor's heartbeats and stay at 0 for kinds that report no steps.

Poll every 3–5 seconds. A 2048² render takes minutes, not seconds, so anything tighter is load without information.

Credentials do not cross

Executor tokens (ufx_…) and API keys (ufk_…) are told apart by prefix before anything is looked up: an API key on an executor endpoint answers 401, and an executor token on a client endpoint answers 401, each saying which credential it wanted. The only path both accept is GET /api/integration/assets/:assetId, and an executor reaches only INPUT assets there.

Revoking a key does not touch the fleet, and revoking a node does not touch keys. Suspending an operator's account does not revoke the keys they created — revoke those here, by hand.

kilndeck — unified multimodal fleet