Surreal Image Generator API

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

Back to the app Your token

Surreal Image Generator paints pictures that are ordinary in every respect but one. In the browser you move through a strangeness space with seven axes — the operative break, the subject, the setting, the light, the palette, the medium, the register — and the app compiles those choices into a single block of prose, a painting brief, 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.

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. Surreal Image 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 Surreal Image Generator run body has exactly two keys:

{"instruction": "<the whole compiled painting 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 seven 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 surreal lettered across the wall behind your chair. You paid for it, and it is ruined.

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

{"task": "surreal",
 "instruction": "An ordinary wooden kitchen chair is filling with still water ...",
 "style": "oil painting",
 "count": 1,
 "$model": "gpt-image"}

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

{"instruction": "An ordinary wooden kitchen chair is filling with still water ... Oil on canvas, visible brushwork, matte finish.",
 "$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 painting brief from step 6.
cat > body.json <<'JSON'
{"instruction": "An ordinary wooden kitchen chair is filling with still water ...", "$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 Surreal Image 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": "surreal-image-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://surreal-image-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 'Surreal Image 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": "surreal-image-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": "An ordinary wooden kitchen chair is filling with still water, held inside its own seat as though the seat were a glass. Nothing else in the room is strange: linoleum floor, a radiator under the window, a chipped enamel kettle on the counter, a dish towel folded over the oven handle. Flat late-afternoon light comes sideways through one window. The palette is muted ochre, dishwater grey and one cold blue. Oil on canvas, visible brushwork, matte finish. Calm and matter-of-fact, as if nobody has noticed. No text, letters, captions, watermarks or signatures anywhere in the image.", "$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 painting

The API contract is four paragraphs long. The interesting problem is the other side of it: what to put in instruction. Surreal Image Generator's whole design assumption is that strangeness is a quantity, and that the useful setting is one. A picture with a single impossible thing in it is unsettling. A picture with six is decoration.

So state one operative break, plainly, in the first sentence, and in ordinary words. Not "surreal", not "dreamlike", not "impossible" — those are labels for the effect, and a model given a label paints the label: melting clocks, floating rocks, the whole exhausted vocabulary. Say instead exactly what is happening, as flatly as you would report a leak. A chair is filling with water. That is a break. A surreal dreamscape of transformation is not a break; it is a mood board.

Then make everything else ordinary, and say so explicitly. The break only registers as a break against a background that is behaving. This is the sentence most people leave out, and leaving it out is why their results look like album covers: given no instruction to stay still, a model will make the floor undulate too, and then nothing is strange because everything is.

Name a mundane anchor object — a specific, unremarkable, nameable thing that the break is not happening to. A chipped enamel kettle. A radiator. A dish towel over the oven handle. The anchor does two jobs: it fixes the scale and the setting for free, and it gives the eye somewhere honest to stand while it works out what is wrong. Vague nouns cannot do this. "Kitchen items" anchors nothing.

Then the last four axes, one short sentence each. Light — direction, time of day, hardness; flat sideways light reads as documentary, which helps. Palette — name three colours, not a temperature; three named colours constrain the whole canvas, "warm tones" constrains nothing. Medium — the physical process and its surface: oil on canvas with visible brushwork, gouache, a matte colour photograph, silverpoint. Register — the attitude the picture takes toward its own contents. Calm and matter-of-fact is the strongest register for this material, because the deadpan is what makes it land; a picture that is visibly excited about being strange has already told you what to feel.

Close with a short negative clause. Image models letter words onto walls, canvases and signage unprompted, and a watermark across your chair is a wasted run.

A worked example, about ninety words, which is roughly the right length — long enough to fix all seven axes, short enough that no single sentence gets outvoted:

An ordinary wooden kitchen chair is filling with still water, held
inside its own seat as though the seat were a glass. Nothing else in
the room is strange: linoleum floor, a radiator under the window, a
chipped enamel kettle on the counter, a dish towel folded over the
oven handle. Flat late-afternoon light comes sideways through one
window. The palette is muted ochre, dishwater grey and one cold blue.
Oil on canvas, visible brushwork, matte finish. Calm and
matter-of-fact, as if nobody has noticed. No text, letters, captions,
watermarks or signatures anywhere in the image.

Read it back and notice what it does not contain: no adjective for the overall effect, no second impossibility, no camera settings, 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 seven axes so you can vary one at a time — hold six fixed, sweep the seventh, and you have a controlled experiment instead of a slot machine.

# Seven 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'
{
  "break":    "An ordinary wooden kitchen chair is filling with still water, held inside its own seat as though the seat were a glass.",
  "subject":  "The chair is the subject, alone and seen straight on, at the centre of the frame.",
  "setting":  "Nothing else in the room is strange: linoleum floor, a radiator under the window, a chipped enamel kettle on the counter, a dish towel folded over the oven handle.",
  "light":    "Flat late-afternoon light comes sideways through one window.",
  "palette":  "The palette is muted ochre, dishwater grey and one cold blue.",
  "medium":   "Oil on canvas, visible brushwork, matte finish.",
  "register": "Calm and matter-of-fact, as if nobody has noticed."
}
JSON

NEG="No text, letters, captions, watermarks or signatures anywhere in the image."

jq --arg neg "$NEG" '
  [.break, .subject, .setting, .light, .palette, .medium, .register, $neg]
  | join(" ")
  | {instruction: ., "$model": "gpt-image"}
' axes.json > body.json

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

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