Impossible Creature Generator API

One token, one endpoint, eight languages. Everything the button does, from a script.

Back to the app Your token

Impossible Creature Generator draws invented animals as plates from a field guide to a world that does not exist. In the browser you move through an eight-axis space — body plan, habitat, adult mass, feeding guild, locomotion, body covering, thermal strategy and plate style — and twenty-two deterministic rules check the combination against real allometry before anything is rendered. The app then compiles those choices, and the anatomy they force, into a single block of prose which it hands to an image model. The web app is a thin front end over a public HTTPS API. Everything it does you can do from a script, and this page is the whole contract.

The coherence engine is the part worth reimplementing rather than calling. It is pure client-side arithmetic over mass, medium, temperature and skeleton type, it costs nothing, and it is what stops you paying to render a 320 kg animal with functional wings.

Base URL:

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

Authentication is one header:

Authorization: Bearer YOUR_TOKEN

The token is app-scoped: it already says which app you are calling, so there is no slug header and no slug in the path. Get one from tokens.html, or mint a guest token with the call in step 1.

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 refused run and a broken connection do not look alike: the first is a well-formed envelope with ok: false, the second is not JSON at all. The helper in step 2 handles both.

Endpoints

Method and pathCosts creditsWhat it is for
POST /guestNoMint an anonymous token. No Authorization header on this one.
GET /meNoWho the token belongs to, and the balance.
POST /estimateNoThe hold a run of this shape would reserve.
POST /runYesStarts a job and returns job_id immediately.
GET /jobs/{job_id}NoPoll a job to a terminal state.

POST /run-stream also exists on the platform. It is a text lane: it streams token deltas over server-sent events, which is exactly nothing when the output is a PNG. An image run gives you no partial picture and no progress deltas, so streaming buys you no earlier information than polling does. Impossible Creature Generator is /run plus GET /jobs/{id}, and this page documents no SSE.

The body is two keys

This is the single most important thing on the page, so it goes before the tutorial rather than inside it. A Impossible Creature Generator run body has exactly two keys:

{"instruction": "<the whole compiled plate brief, as plain prose>", "$model": "gpt-image"}

There is no task field. There is no style field. There is no count field. There is no field for the eight axes, no field for aspect ratio and no field for a seed. The axes are not parameters; they are sentences you have already written into instruction.

Every additional key is concatenated into the text the image model sees, and the image model paints the text it is given. This is why a stray key is not a harmless no-op and not a 400. The API accepts the body, reserves the hold, runs the job and returns 200 OK with a picture that has the words task creature lettered across the plate beside your animal. You paid for it, and it is ruined — and on a field-guide plate, which already invites handwritten labels, it is the single easiest way to waste a run.

Wrong — four keys, and three of them become graffiti:

{"task": "creature",
 "instruction": "A single plate from a naturalist's field guide to a world that does not exist ...",
 "style": "hand-coloured lithograph",
 "count": 1,
 "$model": "gpt-image"}

Right — the style and the count live in the prose, or they do not exist:

{"instruction": "A single plate from a naturalist's field guide to a world that does not exist ... The plate is a hand-coloured lithograph, fine stone-drawn outline with transparent watercolour laid over it by hand.",
 "$model": "gpt-image"}

$model is what makes it an image run at all. Drop it and the request goes to the app's text model, which will cheerfully write you three paragraphs describing the painting you asked for. It also changes how the run is priced: per picture rather than per token. Keep it, spell it exactly gpt-image, and note that $model is not a legal identifier in Go, C# or Java, so those languages build the body as a map or a string rather than a struct.

One run is one picture. There is no batch parameter, because there is no parameter surface at all. Four pictures are four calls to /run, four holds and four jobs to poll.

# Build the body in a file so the shell never has to quote the brief.
# BRIEF is the compiled plate brief from step 6.
cat > body.json <<'JSON'
{"instruction": "A single plate from a naturalist's field guide to a world that does not exist ...", "$model": "gpt-image"}
JSON

# Two keys. Confirm it before you spend anything on it:
jq -e 'keys == ["$model", "instruction"]' body.json >/dev/null \
  && echo "two keys, good" \
  || { echo "extra keys will be painted into the picture" >&2; exit 1; }

1. Get a token

Two ways in, and which one you want depends on whose credits are paying.

Your own token. Open tokens.html on this site, sign in, and copy what it shows you:

app token aut_…

That token is scoped to Impossible Creature Generator and needs no companion header. Send it and you are calling this app.

A guest token. POST /guest is the only route on the API that takes no Authorization header, and the only one that takes a slug. Post {"slug": "impossible-creature-generator"} and the response carries a fresh token and a guest_id. A guest is an anonymous subject with the publisher's sponsored allowance rather than an account, so it can run out mid-script and cannot be topped up. It is the right choice for a demo and the wrong one for a batch.

A token can spend the credits of whoever minted it. Treat it as a password with a billing relationship attached: a secret manager, a keychain, or your CI provider's encrypted variables. Not a git repository, not a container image, not a log line, and never 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.

Every snippet below uses a constant named TOKEN holding the literal YOUR_TOKEN, so the code reads clearly. In anything you deploy, replace that literal with a lookup against your secret store.

# A. Your own token, copied from https://impossible-creature-generator.skillsafe.ai/tokens.html
# The leading space keeps it out of shell history in most shells.
 SF_TOKEN="YOUR_TOKEN"

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

# B. Or mint a guest token. This is the one call with no Authorization header.
SF_TOKEN=$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug": "impossible-creature-generator"}' | jq -r '.data.token')

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

2. 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, and error.details carries the specifics — such as the min_credits you fell short of.

Set a generous read timeout. A picture takes tens of seconds, and although /run itself returns at once, a client that gives up after five seconds will abandon jobs you have already paid for.

# Save as sf.sh and source it. Every later snippet uses sf_call.
SF_TOKEN="YOUR_TOKEN"
SF_BASE="https://api.skillsafe.ai/v1/app-api"

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

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

sf_call GET /me | sf_data

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 subject, and can it afford what you are about to do.

/me returns exactly three fields. That is worth stating flatly, because assuming otherwise is the most-copied mistake against this API:

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

There is no email, no name, no display name and 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 rather than an account, and it can run out halfway through a batch.

SF_TOKEN="YOUR_TOKEN"

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

# {"ok":true,"data":{"subject_type":"user","subject_id":"sub_...","credits":41902}}
# Three fields. That is the whole response.

# 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 $SF_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 two-key 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.

FieldTypeWhat it tells you
modelstringThe concrete model the run would reach.
model_aliasstringThe alias you asked for, echoed back — gpt-image here. Assert on it: if it comes back as something else, your $model key did not survive serialisation and you are about to buy paragraphs of prose instead of a painting.
markup_bpsnumberThe publisher's markup in basis points, already folded into the figures 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.

For an image model the hold is per picture and does not vary with prompt length. A four-word instruction and the full compiled brief from step 6 reserve exactly the same amount, because the renderer prices pictures, not tokens. Two consequences, both useful. First, one estimate covers every brief you will ever send: call it once at start-up and cache the answer for the life of the process. Second, you can estimate with a placeholder string, before you have written a brief at all. A batch of N pictures is N separate runs reserving N times hold_credits; there is no volume discount and no batching endpoint.

hold_credits is a reservation, not a price. It is deliberately pessimistic — a ceiling on what the run could conceivably cost — and it is taken out of your available balance for the duration of the job, then released when the job settles. What you actually pay comes back afterwards as charged_credits on the terminal job, and it varies substantially from picture to picture: the same brief run twice will not necessarily settle at the same figure, and the gap between the hold and the settlement is routinely large. So budget your concurrency against hold_credits, since that is what governs how many runs you can have in flight at once, and report your spend from charged_credits, since that is the money.

Do not hard-code any figure from this page into a billing assumption. Read hold_credits from a live estimate at start-up, and read charged_credits off each finished job.

SF_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 $SF_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"instruction": "estimate probe", "$model": "gpt-image"}' | jq '.data'

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

# Four pictures reserve four times hold_credits, one run at a time.
# What you pay is charged_credits on each finished job, not this number.

5. Paint 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 once the hold was released. This, not hold_credits, is your spend.
errorOn failedThe same code and message shape as the envelope error.

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

The picture is base64 in the job output. Read output.images[0].b64 for the bytes and output.images[0].content_type for what to name the file. Decode the base64 and write it as binary; do not write it as text and do not run it through a string-encoding step on the way to disk.

output.output is the empty string on an image run. That field is where the platform's text lane puts its answer, and every habit carried over from a text app reaches for it first. Here it is present, it is a string, and it is "" — not null, not missing — so a truthiness check on it will never tell you the run failed. It will just quietly hand you nothing. Look in images, always.

Idempotency

Send an Idempotency-Key header on POST /run and a retry after a dropped connection attaches 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 dead job, forever.

Derive the key from a hash of the brief plus an attempt counter. The hash makes accidental duplicates of the same request collapse into one charge; the counter makes a deliberate retry a genuinely new run. Something of the shape sha256(brief)[:16] + ":attempt-" + n is enough.

SF_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 > body.json <<'JSON'
{"instruction": "A single plate from a naturalist's field guide to a world that does not exist. One invented animal, drawn as though from a specimen in front of the artist. The plate is a hand-coloured lithograph: fine stone-drawn outline with transparent watercolour laid over it by hand. The animal stands on two hind limbs with a long stiffened tail as a counterweight. It weighs about 45 kg. It lives on grassland that spends part of every year underwater. It works through fallen and decaying material. It moves in bounds on enlarged hind limbs. It has bare glandular skin with no covering at all. It keeps its core warm and lets its extremities run near freezing. Those facts settle the rest of it: small blunt uniform teeth, with the real processing happening in the gut; a long capacious gut with a heavy caecum, giving a soft rounded underline; conspicuous thermal windows where heat is dumped. There is no lettering anywhere in the picture: no species name, no caption, no annotation, no numbered key, no scale bar, no signature and no border, in any alphabet or language.", "$model": "gpt-image"}
JSON

# Hash of the brief, plus an attempt counter. The counter is what lets a
# deliberate retry be a new run instead of a replay of a failed one.
ATTEMPT=1
DIGEST=$(jq -r '.instruction' body.json | shasum -a 256 | cut -c1-16)
IDEM="$DIGEST:attempt-$ATTEMPT"

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

echo "job $JOB"

DEADLINE=$(( $(date +%s) + 240 ))
while :; do
  RES=$(curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $SF_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 > chair.png
printf '%s' "$RES" | jq -r '"type \(.data.output.images[0].content_type), charged \(.data.charged_credits)"'
# .data.output.output is "" on an image run - the picture is only in images[0].

6. Composing a brief that reads as a field-guide plate

The API contract is four paragraphs long. The interesting problem is the other side of it: what to put in instruction. Impossible Creature Generator's whole design assumption is that a creature is convincing in proportion to how much of it agrees with the rest of it. A bag of borrowed parts reads as collage no matter how well it is painted. An animal whose teeth, gut, limbs and covering are all answering the same question reads as zoology.

So do not ask for "a mythical beast" or "an impossible creature". Those are labels for the effect, and a model given a label paints the label — a griffin, a chimera, a winged lion, the whole exhausted bestiary. State instead the eight decisions that actually determine an animal's shape, one short sentence each, in ordinary words.

Body plan — symmetry, skeleton type, how many limbs and what they are for. This constrains everything downstream: a radial animal has no leading end, and a body held in shape by fluid pressure cannot stand up out of water. Habitat — not a mood, a list of problems: a medium, a temperature, a substrate, a light budget. Adult mass — the axis that does the most work and gets the least attention; give a number. Feeding guild — what it eats, which is a hardware requirement rather than a preference. Locomotion, body covering and thermal strategy — one sentence each. Then plate style: the drawing convention is part of the evidence, and a hand-coloured lithograph reads as an expedition record in a way that "fantasy art" never will.

Then derive the consequences and state them as requirements. This is the step almost everyone skips, and skipping it is why most generated creatures look like costumes. A bulk grazer needs molars worn to flat plates and growing all its life, and a fermentation chamber occupying most of the trunk — so it is barrel-shaped, and that is not a stylistic choice. An ambush predator needs recurved holding teeth, a short simple gut and therefore a narrow waist. Write those out. A model given the cause and the effect draws both; a model given only the cause draws neither.

Check the combination before you spend anything. Most striking creature ideas are impossible for a reason that has a number attached, and the number is usually mass. The heaviest animal that has ever flown weighed about 16 kg, so a flying creature of 300 kg is not bold, it is arithmetic that does not close. Cuticle mass rises faster than the volume it encloses, which caps land arthropods near 4 kg. Bulk fermentation needs a chamber, which puts a floor near a kilogramme under any grazer. The browser app runs twenty-two of these rules before it will render; from a script you should at minimum check mass against your locomotor mode.

Close with a negative clause about lettering, and make it long. "A plate from a field guide" is about the strongest cue there is for painting handwritten species names, a scale bar and a numbered key, because that is exactly what the reference material looks like. Left unsaid, the picture comes back captioned in invented Latin and the run is wasted.

A worked example, about a hundred and forty words, which is roughly the right length — long enough to fix all eight axes and their consequences, short enough that no single sentence gets outvoted:

A single plate from a naturalist's field guide to a world that does
not exist. One invented animal, drawn as though from a specimen in
front of the artist.

The plate is a hand-coloured lithograph: fine stone-drawn outline
with transparent watercolour laid over it by hand.

The animal stands on two hind limbs with a long stiffened tail as a
counterweight. It weighs about 45 kg. It lives on grassland that
spends part of every year underwater. It works through fallen and
decaying material. It moves in bounds on enlarged hind limbs. It has
bare glandular skin with no covering at all. It keeps its core warm
and lets its extremities run near freezing.

Those facts settle the rest of it, and the following are to be drawn
exactly as given: small blunt uniform teeth, with the real processing
happening in the gut; a long capacious gut with a heavy caecum,
giving a soft rounded underline; conspicuous thermal windows where
heat is dumped, and a visible thickening at the base of each limb.

There is no lettering anywhere in the picture: no species name, no
caption, no annotation, no numbered key, no scale bar, no signature
and no border, in any alphabet or language.

Read it back and notice what it does not contain: no word for the overall effect, no "mythical", no "fantastical", no artist's name and no parameters. It is one block of plain prose, which is the only thing the API takes. The code below assembles it from the eight axes so you can vary one at a time — hold seven fixed, sweep the eighth, and you have a controlled experiment instead of a slot machine.

# Eight axes, one sentence each, in this order. jq joins them into the brief
# and writes the two-key body in the same pass.
cat > axes.json <<'JSON'
{
  "frame":      "A single plate from a naturalist's field guide to a world that does not exist. One invented animal, drawn as though from a specimen in front of the artist.",
  "plate":      "The plate is a hand-coloured lithograph: fine stone-drawn outline with transparent watercolour laid over it by hand.",
  "plan":       "The animal stands on two hind limbs with a long stiffened tail as a counterweight.",
  "mass":       "It weighs about 45 kg.",
  "habitat":    "It lives on grassland that spends part of every year underwater.",
  "feeding":    "It works through fallen and decaying material.",
  "locomotion": "It moves in bounds on enlarged hind limbs.",
  "integument": "It has bare glandular skin with no covering at all.",
  "thermo":     "It keeps its core warm and lets its extremities run near freezing.",
  "derived":    "Those facts settle the rest of it, and the following are to be drawn exactly as given: small blunt uniform teeth, with the real processing happening in the gut; a long capacious gut with a heavy caecum, giving a soft rounded underline; conspicuous thermal windows where heat is dumped."
}
JSON

NEG="There is no lettering anywhere in the picture: no species name, no caption, no annotation, no numbered key, no scale bar, no signature and no border, in any alphabet or language."

jq --arg neg "$NEG" '
  [.frame, .plate, .plan, .mass, .habitat, .feeding, .locomotion,
   .integument, .thermo, .derived, $neg]
  | join(" ")
  | {instruction: ., "$model": "gpt-image"}
' axes.json > body.json

jq -r '.instruction' body.json | wc -w   # aim for roughly 140

7. Errors and retries

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.codeRetry?What to do
401unauthorizedNo The token is missing, malformed or no longer valid. Retrying cannot fix it. Mint a fresh one from tokens.html and check the header really reads Bearer followed by the token.
402payment_requiredNo The balance is below min_credits, so the hold could not be taken and nothing ran. Top up, or lower your in-flight count — concurrent holds are the usual cause. error.details carries the shortfall.
400validation_errorNo The body is malformed. Here that almost always means a missing or mangled $model, or an instruction that is not a string. Fix the body; retrying it unchanged will fail identically.
429rate_limitedYes Too many requests. Back off exponentially with jitter. Polling faster than every 1.5 seconds is the most common way to land here.
500 / 502internalYes The renderer failed. The run is not billed and the hold is released. Retry with the attempt counter bumped, or the idempotency replay will hand you the same dead job back.

A clean estimate is not proof that a model runs. /estimate prices a request; it does not execute one. It will happily return a hold for a body that /run then rejects, and it says nothing about whether the renderer is healthy, whether the model is currently reachable, or whether this particular brief will come back refused. Treat it as a budgeting call and nothing more. The first honest signal that the pipeline works end to end is a job that reaches succeeded with bytes in images[0].b64, so make that your smoke test, not an estimate.

Two failure modes deserve separate handling. A transport failure — a dropped connection, a proxy timeout — leaves you not knowing whether the run started. That is exactly what the idempotency key is for: repeat the request with the same key and you attach to the original job rather than paying twice. A job failure — status: "failed" — is a decision the renderer already made, so repeating it with the same key replays the failure. Bump the attempt counter for that one.

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

# Retry only rate_limited and internal. Everything else is a bug in the request.
ATTEMPT=1
while [ "$ATTEMPT" -le 4 ]; do
  DIGEST=$(jq -r '.instruction' body.json | shasum -a 256 | cut -c1-16)
  RES=$(curl -sS -X POST "$BASE/run" \
    -H "Authorization: Bearer $SF_TOKEN" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: $DIGEST:attempt-$ATTEMPT" \
    --data-binary @body.json)

  if [ "$(printf '%s' "$RES" | jq -r '.ok')" = "true" ]; then
    printf '%s' "$RES" | jq -r '.data.job_id'
    break
  fi

  CODE=$(printf '%s' "$RES" | jq -r '.error.code')
  case "$CODE" in
    rate_limited|internal)
      # Exponential back-off with jitter: 2s, 4s, 8s, plus 0-1s.
      sleep $(( 2 ** ATTEMPT ))
      ATTEMPT=$(( ATTEMPT + 1 ))
      ;;
    *)
      echo "not retryable: $CODE" >&2
      printf '%s' "$RES" | jq '.error'
      exit 1
      ;;
  esac
done

Before you ship