# JifChat Canvas API — Guide for AI Agents > Machine-readable OpenAPI spec: [`canvas-openapi.json`](./canvas-openapi.json) You are an AI agent using the JifChat Canvas API to create visuals (images, videos, text) inside infinite-canvas projects. ## Auth Every request needs the account's API key: ``` Authorization: Bearer jc_xxx... ``` Keys act only on the account that created them and spend that account's credits. Create keys in the JifChat UI: Settings → Balance → API Keys (the plaintext is shown exactly once at creation). ## The 30-second flow ``` 1. POST /api/v1/canvas/projects { "title": "my project" } → { _id } 2. POST /api/v1/canvas/projects/:id/run-node { "nodeId": "img-1", "model": "nano-banana-pro", "parameters": { "prompt": "a shiba surfing at sunset" } } → sync: run.status === 'completed', run.outputUrl is your image 3. (video) POST /api/v1/canvas/projects/:id/run-video-node { "nodeId": "vid-1", "model": "seedance-2.5", "parameters": { "prompt": "@Image1 paddles through the wave", "image_urls": [""] } } → async: 202 with run.responseUrl 4. (video) POST /api/v1/canvas/projects/:id/check-run { "responseUrl": "" } → poll every ~10s until { status: 'completed' }; terminal states also return nodeId, creditCost (0 on a failed run), and outputUrl (when present) ``` **Two ways to build the canvas — pick by job size:** 1. **One generation** — `run-node` / `run-video-node` with `parameters.prompt` auto-create the prompt text node, its edge, and the generator. Always send `"position"`. If you omit it, the node is placed at the previous card's right edge + 200 (same y), not stacked on `{0,0}`. An explicit `{x,y}` is clamped to about -20000..20000. 2. **Two or more generations, or a pipeline** — `POST /projects/:id/nodes` for each node, then `POST /projects/:id/edges` for each connection. `PUT /projects/:id` rejects any body that contains `nodes` (nothing in that body is written). Do not PUT the graph. Node body for `POST /nodes` — `{ nodeId, nodeType, position, data }`. Always send `position`. - `textNode`: `data: {"text": ""}` (clamped to 8000 characters; a non-string becomes `''`) - `imageGenerator`: `data: {"model": "nano-banana-pro"}` — the server seeds `status`, `resultImage`, and `errorMessage` - `videoGenerator`: `data: {"model": "seedance-2.5"}` — the server seeds `status`, `resultVideo`, and `errorMessage` Edge body for `POST /edges` — `{ source, sourceHandle, target, targetHandle }` only. The API builds the edge id and stroke. - text: `text-out` → `text-in` (one per generator) - images: `image-out` → `image-in-0` … `image-in-4` (one per slot; `image-in-0` is the first slot, `image-in-1` the second; `image-in-5` is rejected) - video generators also accept `lastframe-in`, `video-in-0` … `video-in-2`, and `audio-in-0` … `audio-in-2` Layout: always send `position`. Gap is 200. Next x = previous card's x + that card's width + 200. Widths (from the frontend `NODE_LAYOUT_SIZE`): textNode 320, imageUpload/videoUpload 320, imageGenerator/videoGenerator/textGenerator 736. A 9:16 video card was measured at 720×1280, so the next shot row is y += 1480. An image prompt is a textNode wired `text-out` → `text-in` — the image run uses that text. Video `@Image1` / `@Video1` / `@Audio1` citations belong on the video node's `data.prompt`, not only on a text node. **Create a fresh project per request** (`POST /projects`) — do not reuse or browse existing ones unless the user explicitly refers to a previous generation. ## Models **Defaults: use `seedance-2.5` for video and `nano-banana-pro` for image unless the task clearly needs something else.** | Medium | Model | Type | |---|---|---| | image | `nano-banana-pro` | text-to-image | | image | `gpt-image` | text-to-image | | video | `seedance-2.5` | reference-to-video (refs optional; ≤9 `image_urls`) | | video | `grok-i2v` | image-to-video (pass `reference_image_urls`) | ## Rules - **NEVER generate brand content text-only.** Models have zero knowledge of real brands — text-only prompts hallucinate wrong colors, no logo, generic locations. Always pass reference images: - Image gen with refs: model becomes `${model}/edit` (e.g. `gpt-image/edit`, `nano-banana-pro/edit`) + `parameters.image_urls` (array of URLs). Base endpoints accept NO refs. - Brand-ref budget: 2–4 images is the sweet spot — 1 logo lockup, 1 palette card, 1 scene plate. nano-banana-pro/edit ≤14, gpt-image/edit ≤16. - Video (seedance-2.0): `image_urls` ≤9, `video_urls` ≤3, `audio_urls` ≤3 (≤12 files total). Cite refs in prompt as `@Image1`, `@Video1`, `@Audio1`. Hailuo uses prose `Image 1` (no `@`). - **Uploading your own assets** (logos, product shots, voice clips): `POST /api/v1/canvas/files/{images|videos|audio}` (multipart, `file` field). Images also need `width` + `height` form fields (pixels). Response: `{ url }` — feed it straight into `image_urls` / `reference_image_urls` / `audio_urls`, or place it on a canvas node via `POST /projects/:id/nodes`. Uploads are free; only generation costs credits. Checks run in this order, each returning 400: `kind` first (`images` | `videos` | `audio`), then `file_id` (UUID if supplied; omit it and one is generated), then the mime allowlist — `/images` takes `image/jpeg|gif|png|webp` (no HEIC/HEIF: this image build has no HEVC decoder, so those bytes are always rejected), `/videos` takes `video/mp4|quicktime|webm|x-m4v|ogg`, `/audio` takes `audio/mpeg|mp3|wav|x-wav|ogg|webm|aac|flac|mp4` — then the size cap (images 30 MB, videos 200 MB, audio 15 MB) and finally `width` + `height` for images. Size is checked before width/height, so an oversize image reports the size limit, not the missing dimensions. Uploads in flight are capped per account (default 2; a third is a 429) and the active file strategy must have an upload handler — `videos` / `audio` on a strategy without one is a 501. An image whose bytes cannot be decoded is a 400 (`Could not decode the uploaded image`), nothing stored. - **Placing nodes**: `POST /projects/:id/nodes` with `{ nodeId, nodeType, data, position? }` — nodeType ∈ `textNode | imageUpload | videoUpload | audioUpload | imageGenerator | textGenerator | videoGenerator`. Optional `position` `{x,y}` must be finite numbers and is clamped to about -20000..20000. Omitted or invalid position places the node at the previous card's right edge + 200, same y (widths: textNode 320, imageUpload/videoUpload 320, audioUpload 345, generators 736). The first node with no position is `{x:0,y:0}`; a later one is not left there. `textNode` takes `data.text`, clamped to 8000 characters the same way a generator `prompt` is (a non-string becomes `''`). `nodeId` must be a non-empty string (≤256 chars). Upload types require `data.mediaUrl` (imageUpload uses `data.imageUrl`) and it must be an **https** Firebase Storage or fal media URL — anything else is a 400 naming the active file storage. It must not carry credentials (`user@`), an explicit port, or any control character; the stored value is the normalised `href`. `filename`/`mimeType` must be strings and `duration` a non-negative number when sent. `data` is whitelisted per node type: unknown keys are dropped, and the server seeds the run/output fields itself (`status`, `resultImage`, `resultVideo`, `outputText`, `errorMessage`, `uploadStatus`) — do not send those; a generator node always starts `idle`, so clients cannot fabricate a completed node. A generator node also accepts `model` (string), `prompt` (a string, capped at 8000 characters — anything else, or a longer value, is truncated to `''`/the cap rather than rejected) and `name`, plus that type's own settings (`resolution`, `aspectRatio`, `quality`, `imageSize`, `duration`, `generateAudio`). Every other client string stored on a node (`model`, `name`, `resolution`, `aspectRatio`, `quality`, `imageSize`, `filename`, `mimeType`) is capped at 256 characters and must not contain control characters (400) — node `data` is served on the public view link, so an unbounded value is a public data-store. Existing nodes are never overwritten. - **Editing nodes vs editing a project**: `POST /projects/:id/nodes` is the only node-writing route on the key surface. `PUT /projects/:id` (title, edges and the other project fields) rejects any body containing `nodes` with a 400 pointing at `/nodes` — and writes nothing at all, so no silent partial update. The browser autosave PUT is unaffected. - **Connecting nodes**: `POST /projects/:id/edges` with `{ source, sourceHandle, target, targetHandle }` — a semantic connection; the API validates it against the canvas rules (handle compatibility, one edge per slot, text fan-in of 1) and builds the edge. Handle ids: text `text-out`→`text-in`; images `image-out`→`image-in-0`…`image-in-4` (`image-in-5` is rejected); video generators also accept `lastframe-in`, `video-in-0`…`video-in-2`, and `audio-in-0`…`audio-in-2`. 400/409 = the graph you asked for isn't valid — read the error, don't retry. - **Prompt convention**: an image generator's prompt is a textNode wired `text-out` → `text-in` (ImageGeneratorNode only enables Run with that edge). The image run uses that text — send the same string as `parameters.prompt`. Passing `parameters.prompt` to `run-node` / `run-video-node` wires that text node and its edge (the text node sits 320px left of `position`) or updates the existing upstream text. The same string is stored on the generator as `data.prompt` after the 8000-character clamp. Video `@` citations bind from the video node's own `data.prompt` only — do not put them only on a text node. - **Discovering models + costs**: `GET /api/v1/canvas/models` returns per-model credit pricing + defaults; when the live table is empty it returns the flat `imageCreditCost`, `imageCreditCost4K` (a 4K image run), and `videoCreditCost`, which is what the run will charge. `GET /api/v1/canvas/balance` returns your remaining credits — check before big runs. - **Grouping projects**: `POST /api/v1/canvas/groups` (`name`; optional body `teamId` to create the group inside a team workspace you belong to), `GET /groups` (optional `?workspace=`), `POST /groups/:id/projects` (projectId), group `credit-limit`. Groups are **team-scoped by membership**, not owner-scoped: `GET /groups` returns your personal groups by default, and with `?workspace=` it returns the groups attached to that team for any member of it (name, creditLimit, teamId, spent — a non-member gets `[]`). A `teamId` you do not belong to is rejected with 403. - **Owner-only**: a key can only touch projects owned by its account. Other projects return 404. - **Billing**: credits are charged to the key owner on success only — a run that fails at the provider is never charged and its record reads `creditCost: 0`, so there is nothing to refund (confirm with `GET /balance`). Video is charged when the poll completes. - **Video is async**: always submit → poll `check-run` with the `responseUrl`. - **Re-running a node while it's running** returns 409. - **`@Image1` prompt references**: for i2v models, reference each image in order (`@Image1`, `@Image2`, …) inside the prompt. ## Errors | Status | Meaning | |---|---| | 401 | missing/invalid API key | | 402 | insufficient credits | | 400 | upload rejected — bad `kind`, bad `file_id`, wrong mime, over the per-kind cap, missing width/height, or undecodable image bytes; or `POST /projects/:id/nodes` with a non-allowlisted `mediaUrl` / bad field type | | 429 | too many concurrent uploads for this account (default cap 2) | | 501 | videos/audio upload not supported by the active file strategy | | 404 | project not found or not owned by you; a write to a trashed project is also a 404 | | 409 | node already has a run in flight | ## Example: chained image → video ```bash KEY="jc_..." BASE="${JIFCHAT_BASE_URL:-https://chat.jif.dev}" # 1. project PID=$(curl -s -X POST "$BASE/api/v1/canvas/projects" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"title":"agent demo"}' | jq -r ._id) # 2. image (sync) IMG=$(curl -s -X POST "$BASE/api/v1/canvas/projects/$PID/run-node" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{"nodeId":"img-1","model":"nano-banana-pro","parameters":{"prompt":"a shiba inu on a surfboard, watercolor"}}') IMG_URL=$(echo "$IMG" | jq -r .run.outputUrl) # 3. video (async) RUN=$(curl -s -X POST "$BASE/api/v1/canvas/projects/$PID/run-video-node" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d "{\"nodeId\":\"vid-1\",\"model\":\"seedance-2.5\",\"parameters\":{\"prompt\":\"@Image1 paddles through the wave\",\"image_urls\":[\"$IMG_URL\"],\"duration\":5,\"aspect_ratio\":\"16:9\",\"resolution\":\"720p\"}}") RESP=$(echo "$RUN" | jq -r .run.responseUrl) # 4. poll curl -s -X POST "$BASE/api/v1/canvas/projects/$PID/check-run" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d "{\"responseUrl\":\"$RESP\"}" ```