API & automation

Driving Meme Generator from your own code.

Back to the app Your token

Base URL https://api.skillsafe.ai/v1/app-api. Every response is an envelope: {"data": …} on success, {"error": {"code", "message", "details"}} on failure. The bearer token identifies the app as well as the caller, so there is no slug header to set.

The shape of this app

A meme here is made of three things, and only two of them are model runs.

StageWhat it isWhere it happens
The wordsA text run. Returns captions, two alternative caption sets, and a scene to paint.This API
The pictureAn image run, reached with a $model override. Returns one 1024×1024 picture as base64.This API
The typesettingDrawing the caption onto the picture, sized to fit.Your side. No run, no charge.

The brief sent on the image run forbids lettering inside the picture. That is deliberate and you should keep it if you build your own client: an image model cannot reliably spell, cannot be held to a character limit, and cannot be edited afterwards. Typeset the caption yourself and it is correct, it fits, and changing it is free.

The four layouts and their caption slots

formatSlotsWhat the picture has to be
impacttop (70), bottom (70) — at least oneOne subject, calm top and bottom fifths
bannercaption (200)One scene, whole frame usable
splitcaption (90, optional), left (60), right (60)One image as two equal panels with a vertical gutter
labelscaption (90, optional), label1label4 (46 each; the first two required)Two to four separated elements with space around each

Error codes

CodeStatusWhat it means here
unauthorized401No token, or one the server no longer accepts. A cold guest call answering 401 is normal — mint a session first.
payment_required402Balance below min_credits. Call /estimate first and compare against /me.
validation_error400A malformed input. On an image run this is also what a rejected extra field such as $files looks like.
rate_limited429Back off. Do not tight-loop a poll.
internal5xxThe run itself failed. On a failed job the error field is a plain string about as often as an object — read both shapes.

1. A tiny client

One helper, reused by every step below. Keep the token out of your source and out of your repository.

2. Who am I

/me returns exactly three fields — subject_type, subject_id and credits. There is nothing else in it, so signed in means subject_type reads user.

3. What it will cost

Free, and it makes no job. Estimate both runs: their prices come from different rate blocks and adding one leg's figure to nothing is how a client ends up quoting half the real reserve.

4. The writing run

Submit, then poll /jobs/{id} until status is succeeded or failed. The reply text is at output.output.

5. The painting run

Same submit-and-poll, different payload and a very different output field.

The writing run's output contract

The reply is one JSON object. The app parses it with a walker that strips a markdown fence if one is present and, if the run hit its output cap mid-string, discards the trailing fragment and closes the open brackets — so a truncated reply still yields whatever parsed. If you build your own client, do the same; models truncate.

{
  "title": "Quick Question, Instant Silence",
  "lines": [
    {"slot": "top",    "text": "I open with quick question."},
    {"slot": "bottom", "text": "The room goes quiet for exactly as long as it needs to."}
  ],
  "alts": [
    {"label": "flatter",  "lines": [{"slot": "top", "text": "…"}, {"slot": "bottom", "text": "…"}]},
    {"label": "specific", "lines": [{"slot": "top", "text": "…"}, {"slot": "bottom", "text": "…"}]}
  ],
  "scene": "A cramped video-call grid of six coworkers frozen mid-blink…",
  "why": "Everyone recognizes the exact length of a silence nobody will admit they're counting."
}

A refusal comes back instead as {"refused": true, "reason": "…"} and carries nothing else. The app refuses the same five categories in the browser before the run is made — harassment and pile-ons, contempt aimed at a group for what they are, a meme aimed at a real named private person, sexual content, and anything sexual, suggestive or hostile involving a child — so in practice the model's refusal is the second line, not the first.

Slot ids not belonging to the requested format are dropped. Captions over their limit are cut and flagged. Both are reported back to the user rather than absorbed silently.

The image run's input contract

Exactly two fields. instruction and $model. Every additional key is concatenated into the text the renderer sees and painted into the picture as literal words — a format field will get you a picture with the word "impact" in it. $files is rejected outright on an image run, so there is no image-in / image-out single call; anything that has to read a picture needs a text model with $files to write a brief first.

Output is at output.images[0] as {content_type, b64}. output.output is the empty string on an image run, and reading it is the first mistake a text-lane habit produces here.

Pricing

Two runs, priced differently. The text run is priced per token and its hold moves with the length of the guide and the idea. The image run is priced per image out of a separate rate block, and its hold does not move with prompt length at all — so one estimate covers every brief you will ever send.

Quote the hold, never a settled price. The settled charge on an image run varies by several times from picture to picture on the same alias with the same hold, so any number written down here would be wrong for most runs. /estimate is free, makes no job, and echoes back model, model_alias, markup_bps, hold_credits and min_credits.

6. Streaming the writing run

Server-sent events. Frames are separated by a blank line; within a frame an event: line names the event and a data: line carries JSON. The event names are job, delta, done, pending and error. There is no {"type":"delta"} envelope, and a parser written against one never fires.

A frame on the wire looks like this:

event: job
data: {"job_id":"job_..."}

event: delta
data: {"text":"{\"title\":\"Quick"}

event: done
data: {"status":"succeeded","output":{"output":"..."},"charged_credits":41}

The content guard does not run here

The app refuses five categories of request — harassment and pile-ons, contempt aimed at a group for what they are, a meme aimed at a real named individual, sexual content, and anything sexual, suggestive or hostile involving a child. That guard is client-side. It runs in the page, before any estimate or hold, and it is described that way rather than as a property of the service.

Calling this API directly puts you outside it. What still applies is the app's own system prompt, which refuses the same things at the model — a different check, and not a substitute for the first. If you are building a client on top of this app, port the guard or write your own; do not assume the endpoint enforces one. The rules the guard implements are set out in llms.txt, and its source ships at /guard.js.

The writing guide, verbatim

The guide field is not a summary — it is the whole instruction set, served at /plan-prompt.js and readable by anybody. Fetch it, send it, or write your own; the run has no hidden half.

Idempotency

Pass an Idempotency-Key header on every /run. One caution learned the expensive way: an idempotent replay returns the original job even when that job failed. A key that repeats after a restart echoes the old failure back and no new job appears. Salt the key with something that changes per process, not only with a hash of the input.

Getting a token

The token page shows the token this browser already holds, whose session it is, and how to replace it — no DevTools needed. A guest token is minted by POST /guest with {"slug": "meme-generator"} and carries no balance, so it can read but not run.