Anime Art Generator API

Two lanes, one token, eight languages. Everything the buttons do, from a script.

Back to Anime Art Generator

Anime Art Generator draws original anime illustrations and writes shot-by-shot storyboards. The web app is a thin front end over a public HTTPS API, so anything you can click you can also call. This page is the whole contract: the two request shapes, the response envelope, the polling and streaming mechanics, and the vocabulary the storyboard lane speaks.

Base URL:

https://api.skillsafe.ai/v1/app-api

Authentication is one header:

Authorization: Bearer YOUR_TOKEN

The token is an app-scoped credential that begins with aut_. There is no app-slug header and no slug in the path — the token itself says which app you are calling, which is why a token minted for another app will authenticate and then behave nothing like this one. Grab yours from tokens.html.

The envelope

Every response body, at every status code, is the same two-branch envelope. Read data; never read fields off the top level.

{"ok": true, "data": { ... }}

{"ok": false, "error": {"code": "...", "message": "...", "details": { ... }}}

Check ok before you touch anything else. A failed run and a transport failure look different: the first is a well-formed envelope with ok: false, the second is not JSON at all. The helper in step 1 handles both.

Endpoints

Method and pathCosts creditsWhat it is for
GET /meNoWho the token belongs to, and the balance.
POST /estimateNoThe hold that a run of this shape would reserve.
POST /runYesStarts a job, returns job_id immediately.
GET /jobs/{job_id}NoPoll a job to a terminal state.
POST /run-streamYesRuns and streams text back as SSE.

0. Two lanes, two body shapes

This is the one thing to get right before anything else. Anime Art Generator serves two completely different jobs behind a single /run endpoint, and the shape of the body you post decides which one you get. Post the wrong shape and the API does not stop you: you get 200 OK, a charged job, and a result that is quietly the wrong kind of thing.

LaneBody you sendWhere the answer is
illustration
an image run
instruction — the compiled brief
$model"gpt-image"
and nothing else
data.output.images[0].b64
data.output.images[0].content_type
data.output.output is empty
storyboard
a text run, streamable
task: "storyboard", premise, count, register, pace, board_instructions data.output.output — one JSON object
or SSE deltas from /run-stream

The illustration body is exactly two keys. There is no task field on that lane and no room for one. Everything you put in the body of an image run is concatenated into the single block of text the renderer receives, and the renderer paints text it is given. Add "task": "illustration" or "count": 1 or a stray "style" and you will get a picture with the words task illustration lettered across it. Two keys. Nothing else.

The $model override is what makes it an image run at all. Drop it and the request goes to the app's text model, which will happily write you several paragraphs describing the illustration you wanted. It also changes how the run is priced: per image instead of per token.

One run is one picture. There is no batch parameter. Six illustrations are six calls to /run, six holds, and six jobs to poll.

# Lane 1 - illustration. Exactly two keys, and BRIEF is the compiled brief from step 5.
cat > image-body.json <<'JSON'
{"instruction": "Original anime illustration of an invented character design ...", "$model": "gpt-image"}
JSON

# Lane 2 - storyboard. A task router plus its parameters.
cat > board-body.json <<'JSON'
{"task": "storyboard",
 "premise": "A night courier finds the market arcade already shuttered.",
 "count": 5,
 "register": "ova90s",
 "pace": "quiet",
 "board_instructions": "...the full text served at /board-prompt.js..."}
JSON

Content policy

Anime Art Generator refuses three things. In the browser the refusal happens client-side, before a single credit is reserved — which means that as an API caller you are standing on the other side of that guard. Nothing about that makes the rules optional. The same three rules are written into the app's system prompt and enforced independently on every run, so a request that the web app would have blocked will instead be refused after you have paid for the attempt.

  1. No sexualised or suggestive imagery, at any apparent age, in any register. Chibi and comedic registers are not an exemption; they are just registers.
  2. Nothing suggestive, romantic or revealing involving a character who reads as a minor. How a character reads is what matters, not what a prompt asserts about them.
  3. Invented characters only. No likeness of a real person, living or dead, and no existing copyrighted character. Anime Art Generator is for designs that did not exist before the run.

Every brief the app compiles opens with a fixed, non-removable clause asking for an invented character design in a clothed, wholesome scene, and closes with a negative clause that keeps text, watermarks and duplicated limbs out of the frame. Both are visible in the worked example in step 5. If you compile your own instruction rather than reusing the app's, carry an equivalent opening clause. It is not decoration; it is the sentence that keeps an ambiguous subject description from resolving somewhere you did not intend.

On the storyboard lane, a request the model declines does not fail the run. It comes back in the refusals array with what was asked and why, and the remaining panels are written normally.

1. A tiny client

Three things repeat on every call: the base URL, the bearer header, and unwrapping the envelope. Write them once. The helper below takes a method and a path, sends JSON, raises when ok is false, and returns data — every later step assumes it exists and calls it call.

Give the error object a real home while you are at it. error.code is the field you branch on; error.message is for humans; error.details carries the useful specifics, such as the min_credits you fell short of.

# Save as af.sh and source it. Every later snippet uses af_call.
ANIME_FORGE_TOKEN="YOUR_TOKEN"
AF_BASE="https://api.skillsafe.ai/v1/app-api"

af_call() {          # af_call METHOD PATH [BODY_FILE]
  method="$1"; path="$2"; body_file="$3"
  if [ -n "$body_file" ]; then
    curl -sS -X "$method" "$AF_BASE$path" \
      -H "Authorization: Bearer $ANIME_FORGE_TOKEN" \
      -H "Content-Type: application/json" \
      --data-binary "@$body_file"
  else
    curl -sS -X "$method" "$AF_BASE$path" \
      -H "Authorization: Bearer $ANIME_FORGE_TOKEN"
  fi
}

# Unwrap with jq. .ok is the gate; everything you want is under .data.
af_data() { jq -e 'if .ok then .data else error("app-api: " + .error.code + ": " + .error.message) end'; }

af_call GET /me | af_data

2. Getting a token

Open tokens.html on this site, sign in, and copy the token it shows you. It looks like this:

app token aut_…

The token is scoped to Anime Art Generator, so it cannot be pointed at another app, and it needs no companion header — no slug, no app id, nothing. Send it and you are calling this app.

A token can spend the credits of whoever minted it. Treat it exactly as you would treat a password with a billing relationship attached. It belongs in a secret manager, a keychain, or your CI provider's encrypted variables. It does not belong in a git repository, in a container image, in a log line, or anywhere in a front-end bundle — shipping it to a browser publishes it to everyone who opens the page. If one leaks, mint a fresh one from tokens.html; that is the whole remediation.

In every snippet below the token is a constant named TOKEN with the placeholder value YOUR_TOKEN, so the code reads clearly. In anything you actually deploy, replace that literal with a lookup against your secret store.

# Set it once per shell. Leading space keeps it out of history in most shells.
 ANIME_FORGE_TOKEN="YOUR_TOKEN"

# Or prompt for it, so it never lands in a file at all:
#   read -rs -p 'Anime Art Generator token: ' ANIME_FORGE_TOKEN; echo

case "$ANIME_FORGE_TOKEN" in
  aut_*) echo "ok: app-scoped token" ;;
  *)     echo "not an app token - copy one from /tokens.html" >&2; exit 1 ;;
esac

3. Check the session and the balance

GET /me is free, instant, and the right first call in any script. It answers two questions: is this token attached to a real account, and can it afford what you are about to do.

/me returns exactly three fields. This is the single most copied mistake against this API, so it is worth stating flatly:

FieldTypeMeaning
subject_typestring"user" or "guest".
subject_idstringAn opaque identifier. Stable, but not a user id you can look anything up with.
creditsnumberThe spendable balance right now.

There is no email, no name, no display name, no id. Code that reaches for me.email or me.id reads undefined and then fails somewhere far away from the cause. Signed in means subject_type === "user" — that comparison is the entire test. A guest subject has a balance too, but it is the publisher's sponsored allowance, not an account, and it can run out mid-script.

ANIME_FORGE_TOKEN="YOUR_TOKEN"

curl -sS "https://api.skillsafe.ai/v1/app-api/me" \
  -H "Authorization: Bearer $ANIME_FORGE_TOKEN"

# {"ok":true,"data":{"subject_type":"user","subject_id":"sub_...","credits":48210}}

# Signed in is subject_type == "user". Nothing else in there says so.
curl -sS "https://api.skillsafe.ai/v1/app-api/me" \
  -H "Authorization: Bearer $ANIME_FORGE_TOKEN" \
  | jq -e '.data.subject_type == "user"' >/dev/null \
  && echo "signed in" || echo "guest token"

4. Estimate before you run

POST /estimate takes the same body you would send to /run and tells you what that run would reserve. It is free, it creates no job, and it charges nothing. Call it once at start-up and cache the answer.

For an image model the hold is per image and does not vary with prompt length. A twelve-word instruction and the 1,700-character brief in step 5 reserve exactly the same amount, because the renderer prices pictures, not tokens. That is why one estimate is enough for every brief you will ever send on the illustration lane — and why it is safe to estimate with a placeholder string instead of building a real brief first. A batch of N pictures is N separate runs and reserves N times the returned hold_credits; there is no volume discount and no batching endpoint.

FieldTypeWhat it tells you
modelstringThe concrete model the run would reach.
model_aliasstringThe alias you asked for, echoed back — gpt-image here. Worth asserting on: if it is not what you sent, your $model key did not survive serialisation.
markup_bpsnumberThe publisher's markup in basis points, already folded into the numbers below.
hold_creditsnumberWhat /run would reserve against your balance.
min_creditsnumberThe balance floor. Below this the run is rejected with payment_required before anything starts.
sponsor_enabledbooleanWhether the publisher is paying for guest runs.

hold_credits is a reservation, not a price. It is deliberately pessimistic: the ceiling of what the run could conceivably cost, taken out of your available balance for the duration and released when the job settles. The gap is large. One measured illustration on this app reserved 2,652 credits ($0.265) and settled at 618 ($0.062) — and a sibling app measured 96 on the same renderer, so treat the settled figure as variable and the hold as the only number you can plan against. Budget your concurrency against the hold, because that is what determines how many runs you can have in flight at once; report your spend from the charged_credits on the terminal job, because that is what you actually paid.

ANIME_FORGE_TOKEN="YOUR_TOKEN"

# The instruction can be a placeholder: an image hold does not depend on it.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
  -H "Authorization: Bearer $ANIME_FORGE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"instruction": "estimate probe", "$model": "gpt-image"}' | jq '.data'

# {
#   "model": "gpt-image-2",
#   "model_alias": "gpt-image",
#   "markup_bps": 2000,
#   "hold_credits": 2652,
#   "min_credits": 2652,
#   "sponsor_enabled": false
# }

# Six pictures reserve six times hold_credits, one run at a time.

5. Draw a picture: /run then poll

POST /run does not wait. It reserves the hold, queues the job, and returns {"job_id": "..."} straight away. You then poll GET /jobs/{job_id} until status is succeeded or failed.

Field on the jobWhen it appearsMeaning
job_idAlwaysThe handle you poll with.
statusAlwaysqueued, running, succeeded, failed. The first two are non-terminal; keep polling.
outputOn succeededHolds images and output.
charged_creditsOn any terminal statusWhat you actually paid, after the hold was released. This, not hold_credits, is your spend.
errorOn failedSame code and message shape as the envelope error.

Poll every 1.5 seconds and give up after 240. A picture normally lands in 20 to 40 seconds. Tighter polling buys you nothing and will earn you a rate_limited; a shorter ceiling will abandon jobs that were about to succeed and that you have already paid for.

The image is base64 in the job output. Read output.images[0].b64 and output.images[0].content_type. Note that output.output — the field the text lane uses — is the empty string on an image run. It is not null and not missing, so a truthiness check on it will not tell you which lane you are in; look for images instead.

Idempotency

Send an Idempotency-Key header on POST /run and a retry after a dropped connection will attach to the job you already started instead of paying for a second one. But replaying a key returns the original job even when that job failed. A key derived only from your inputs therefore turns a transient renderer failure into a permanent one: every retry re-serves the same failed job, forever. Put a per-attempt salt in the key — an attempt counter or a fresh UUID per try — so a deliberate retry is a new run while an accidental duplicate is not.

The brief below is the real thing: exactly what the app compiles and sends when you press the button. Note the opening clause about an invented character in a clothed, wholesome scene, and the closing clause listing what must not appear. Keep both if you write your own.

ANIME_FORGE_TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"

# Exactly two keys. The brief goes in a file so the shell never has to quote it.
cat > brief.json <<'JSON'
{"instruction": "Original anime illustration of an invented character design — not an existing copyrighted character and not the likeness of any real person. Everyone in frame is fully and modestly clothed, in an ordinary wholesome moment. Subject: a night courier in a long grey coat, cropped dark hair, a canvas satchel over one shoulder, standing under the last lit lamp of a closed market arcade. Style: drawn as a 1990s direct-to-video OVA cel: hand-inked lines of varying weight, dense airbrushed gradients, deep saturated cel paint, slight film grain and the faint softness of an analogue transfer. Linework: brush lineart that tapers and swells, heavier on shadow-side contours. Shading: soft airbrushed gradients blending shadow into light. Palette: a cool palette of indigo, teal and pale cyan with a single warm accent. Framing: a wide establishing shot, the figure small inside a readable place. Composition: three clear depth layers, something framing the shot in the foreground. Background: a narrow city street at night, signage glowing out of focus. Mood: quietly melancholy, gaze lowered and away. Lighting: warm lantern light from below the eyeline, strong falloff into dark. Era: finished the way 1990s cel animation was, painted acetate over painted backgrounds. Finish: framed as a still from the episode itself, nothing posed for the camera. This version: leaning against something just out of frame. Consistent line weight, correct hand and eye anatomy, pupils and highlights aligned, clothing folds that follow the pose, and a single coherent light direction. No text, letters, numbers, captions, speech bubbles, watermarks, signatures, logos, borders, frames, collage panels, split screens or duplicated limbs anywhere in the image.", "$model": "gpt-image"}
JSON

# A per-attempt salt: retrying deliberately must not replay a failed job.
ATTEMPT=1
IDEM="courier-arcade-$(date +%Y%m%d)-attempt-$ATTEMPT"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $ANIME_FORGE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $IDEM" \
  --data-binary @brief.json | jq -r '.data.job_id')

echo "job $JOB"

DEADLINE=$(( $(date +%s) + 240 ))
while :; do
  RES=$(curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $ANIME_FORGE_TOKEN")
  STATUS=$(printf '%s' "$RES" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && { printf '%s' "$RES" | jq '.data.error'; exit 1; }
  [ "$(date +%s)" -ge "$DEADLINE" ] && { echo "timed out after 240s" >&2; exit 1; }
  sleep 1.5
done

printf '%s' "$RES" | jq -r '.data.output.images[0].b64' | base64 --decode > panel.png
printf '%s' "$RES" | jq -r '"type \(.data.output.images[0].content_type), charged \(.data.charged_credits)"'
# output.output is "" on an image run - the picture is only ever in images[0].

6. Board a sequence: /run-stream

The storyboard lane turns a one-line premise into an ordered set of panels, each one already carrying the attribute set the illustration lane needs. It is a text run, so it streams.

FieldTypeNotes
taskstringMust be the literal "storyboard". This is the router.
premisestringOne or two sentences. The whole sequence is derived from it.
countnumberHow many panels, 3 to 8.
registerstringThe drawing register for the board. One of the seven below.
pacestringsteady, slow, fast, quiet or comedic. Governs how much happens between panels.
board_instructionsstringThe lane's full instruction set. Required. See below.

board_instructions is not optional

Anime Art Generator keeps the storyboard lane's long instruction set out of its system prompt and ships it as a served asset instead, so the illustration lane's prompt can stay four lines long and free of storyboard vocabulary it would otherwise try to paint. The consequence for you is direct: the app fetches that asset from its own bundle and sends it on every single storyboard run, and so must you. Omit it and the model has no schema, no vocabulary and no panel discipline; you get loose prose where a JSON object should be.

It is served at https://anime-art-generator.skillsafe.ai/board-prompt.js. The asset is a JavaScript module so the app can import it; if you fetch it over HTTP, send the instruction text it exports. Fetch it once at start-up, cache it in memory, and reuse the same string for every run — it does not vary per request.

What comes back

One JSON object. Nothing before it, nothing after it, no code fence. On /run it arrives whole at data.output.output as a string you parse; on /run-stream it arrives as text deltas you concatenate into the same string.

KeyTypeContents
boardobjecttitle, logline, register.
panelsarrayReading order. One object per panel: order (number), then beat, caption, action and look (all strings), then attributes.
panels[].attributesobjectExactly eleven string keys: register, linework, shading, palette, finish, framing, composition, background, mood, lighting, era. Every value is drawn from the vocabulary below.
board_notesobjectflow and continuity — how the panels read as a sequence, and what has to stay consistent across them.
refusalsarrayZero or more {asked, reason} objects. Usually empty. A refused panel does not fail the run.

Abbreviated to one panel:

{
  "board": {
    "title": "Last Lamp",
    "logline": "A night courier reaches the arcade after the shutters are down.",
    "register": "ova90s"
  },
  "panels": [
    {
      "order": 1,
      "beat": "arrival",
      "caption": "Everything is already closed.",
      "action": "The courier stops at the arcade mouth, the satchel swinging forward.",
      "look": "Wide, the figure small under the single lamp still burning.",
      "attributes": {
        "register": "ova90s",
        "linework": "variable",
        "shading": "airbrush",
        "palette": "cool-twilight",
        "finish": "production",
        "framing": "wide",
        "composition": "layers",
        "background": "citynight",
        "mood": "melancholy",
        "lighting": "lantern",
        "era": "1990s"
      }
    }
  ],
  "board_notes": {
    "flow": "Three wides bracket one close-up so the turn lands where it should.",
    "continuity": "The satchel stays on the left shoulder; the lamp is the only warm source."
  },
  "refusals": []
}

Controlled vocabulary

Every value in attributes comes from this closed set. These are the same tokens the illustration lane's brief compiler understands, which is what lets a panel be drawn without a translation step.

FieldAllowed values
registercel, watercolour, ova90s, modern, chibi, manga, pastel
lineworkclean, bold, variable, sketchy, fine, none
shadingflat, twotone, airbrush, painterly, screentone, minimal
palettenatural, warm-sunset, cool-twilight, pop, muted, pastel, neon, sepia, duotone, mono
finishproduction, keyvisual, novelcover, panel, concept, sticker
framingcloseup, bust, halfbody, fullbody, wide, lowangle, highangle, dutch, overshoulder
compositionthirds, centred, diagonal, layers, negative-left, negative-right
backgroundplain, white, speedlines, classroom, citynight, shrine, seaside, forest, rooftop, interior, abstract
moodcheerful, calm, melancholy, determined, tense, wistful, comedic, awe, weary
lightingdaylight, golden, backlit, neon, moonlight, noon, lantern, overcast, toplight
eranow, 2010s, 1990s, 1980s, 1970s, timeless

A value outside the vocabulary is not an error. The app replaces it with that field's documented default and reports the substitution rather than failing the board. So validate the attributes you receive if the exact token matters to your pipeline — a run that quietly swapped screentone for something else still returns ok: true, and you will only notice in the picture.

SSE framing

/run-stream answers with text/event-stream: lines beginning data: followed by one JSON payload, each event terminated by a blank line.

data: {"delta": "{\"board\": {\"title\": \"Last"}

data: {"delta": " Lamp\", \"logline\": \"A night"}

data: {"job": {"job_id": "job_...", "status": "succeeded", "charged_credits": 41, "output": {"output": "{ ...the whole object... }"}}}

Two rules make a stream reader robust here. Accumulate only the text chunk — concatenating the deltas in arrival order reproduces exactly the string /run would have returned at data.output.output, and it is not valid JSON until the last one has arrived, so parse once at the end and never mid-stream. Skip frames you do not recognise — a payload that is not JSON, or that carries neither a chunk nor the terminal job, is informational; ignoring it costs nothing and keeps your reader working when a frame type is added. Stop when the frame carrying the terminal job arrives; that frame is also where charged_credits lives.

ANIME_FORGE_TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"

# Fetch the instruction set once and fold it into the body as a JSON string.
curl -sS "https://anime-art-generator.skillsafe.ai/board-prompt.js" -o board-prompt.txt

jq -n --rawfile prompt board-prompt.txt '{
  task: "storyboard",
  premise: "A night courier reaches the arcade after the shutters are down.",
  count: 5,
  register: "ova90s",
  pace: "quiet",
  board_instructions: $prompt
}' > board-body.json

# -N and --no-buffer: without them curl holds the stream in its output buffer.
curl -sS -N --no-buffer -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $ANIME_FORGE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  --data-binary @board-body.json \
  | sed -u -n 's/^data: //p' \
  | jq -j --unbuffered 'if .delta then .delta elif .text then .text else empty end' \
  > board.json

jq '.board.title, (.panels | length), .refusals' board.json

7. Putting it together

The two lanes are designed to meet in the middle: a storyboard panel already carries eleven attribute tokens, and the illustration lane's brief is those tokens written out as prose. A full pipeline is three moves.

  1. Estimate once, at start-up. The image hold never changes, so cache hold_credits and use it to decide how many pictures you can have in flight.
  2. Board the premise on /run-stream, sending board_instructions with it, and parse the single JSON object out of the accumulated deltas.
  3. Compile a panel into an instruction and send it down the illustration lane: opening clause, the panel's action as the subject, one labelled sentence per attribute, then the closing negative clause. Two keys, poll, decode.

The compiler is the only piece that is genuinely yours to write, and it is mechanical: each attribute token maps to a fixed phrase, and the phrases are joined in a fixed order. Keep the opening clause and the negative clause exactly where they are — the first is what makes the character invented, the second is what keeps captions and watermarks out of the frame. Compile from panel.attributes, not from panel.look: the prose fields are for a human reading the board, the tokens are what the renderer was tuned on.

Panels are independent runs, so nothing carries between them automatically. If you want a consistent character across a whole board, put the same subject sentence in every panel's brief and let board_notes.continuity tell you which details have to stay fixed.

ANIME_FORGE_TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"

OPENING='Original anime illustration of an invented character design — not an existing copyrighted character and not the likeness of any real person. Everyone in frame is fully and modestly clothed, in an ordinary wholesome moment.'
NEGATIVES='No text, letters, numbers, captions, speech bubbles, watermarks, signatures, logos, borders, frames, collage panels, split screens or duplicated limbs anywhere in the image.'

# 1. Estimate once. The number is the same for every brief.
curl -sS -X POST "$BASE/estimate" \
  -H "Authorization: Bearer $ANIME_FORGE_TOKEN" -H "Content-Type: application/json" \
  -d '{"instruction": "probe", "$model": "gpt-image"}' | jq '.data.hold_credits'

# 2. Board the premise (body built in step 6) and keep the JSON object.
curl -sS -N --no-buffer -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $ANIME_FORGE_TOKEN" -H "Content-Type: application/json" \
  --data-binary @board-body.json \
  | sed -u -n 's/^data: //p' \
  | jq -j --unbuffered 'if .delta then .delta else empty end' > board.json

# 3. Compile panel 3 into an instruction: opening, subject, attributes, negatives.
jq -r --arg open "$OPENING" --arg neg "$NEGATIVES" '
  .panels[] | select(.order == 3) as $p
  | [$open,
     "Subject: " + $p.action,
     "Style: drawn in the " + $p.attributes.register + " register.",
     "Linework: " + $p.attributes.linework + ".",
     "Shading: " + $p.attributes.shading + ".",
     "Palette: " + $p.attributes.palette + ".",
     "Framing: " + $p.attributes.framing + ".",
     "Composition: " + $p.attributes.composition + ".",
     "Background: " + $p.attributes.background + ".",
     "Mood: " + $p.attributes.mood + ".",
     "Lighting: " + $p.attributes.lighting + ".",
     "Era: " + $p.attributes.era + ".",
     "Finish: " + $p.attributes.finish + ".",
     $neg] | join(" ")' board.json > panel3.txt

jq -n --rawfile i panel3.txt '{instruction: $i, "$model": "gpt-image"}' > panel3.json

# Then POST /run with panel3.json and poll, exactly as in step 5.

Error codes

An error is a normal envelope with ok: false. Branch on error.code, not on the HTTP status — the status is a summary, the code is the fact.

HTTPerror.codeWhat to do
401unauthorized The token is missing, malformed or no longer valid. Do not retry — mint a fresh one from tokens.html and check the header really reads Bearer followed by the token.
402payment_required The balance is below min_credits, so the hold could not be taken and nothing ran. Top up, or lower your in-flight count. error.details carries the shortfall.
400validation_error The body is malformed. On the illustration lane the usual cause is a missing or mangled $model; on the storyboard lane it is a count outside 3 to 8 or a missing task. Fix the body; retrying unchanged will fail identically.
429rate_limited Too many requests. Back off exponentially with jitter and retry. Polling faster than every 1.5 s is the most common way to land here.
500 / 502internal The renderer failed. The run is not billed and the hold is released. Retry — with a fresh idempotency salt, or the replay will hand you the same failed job back.

Before you ship