← Quote Generator / API
Your token

Driving Quote Generator from your own code

Quote Generator is a SkillSafe app, so everything the web page does is reachable over HTTP. The contract is small and it is the same in both directions: you post one flat object describing the batch you want, and you get back one plain-text document — no JSON, no code fences — holding the aphorisms and the model's account of how each one is built. This page documents that exactly as app.js implements it.

Base URL: https://api.skillsafe.ai/v1/app-api
Slug: quote-generator · Model: gpt-terra, which resolves to gpt-5.6-terra. Runs are metered and cost credits; /me and /estimate do not.

What the API does not give you, and it is the important part. Neither of the two checks this app is built around runs on the server. The known-saying check (corpus.js and known.js) and the craft check (craft.js) both run in your browser, after the reply lands, over every field of it. Over the API you receive the model's text and nothing else: no match report, no craft findings, no crowding figure.

So treat an API reply the way this app treats one before it has checked it — as a draft that has not yet been looked at. Those three files are plain JavaScript with no dependencies and no network calls. If you want the check, take them.

The envelope

Every response carries the same wrapper. ok tells you which branch you are on; the payload is under data, the failure under error.

{"ok": true, "data": {"job_id": "job_...", "status": "succeeded", "charged_credits": 812, "output": {"output": "SUBJECT: ...\n--\nLINE: ...\n--\n"}}} {"ok": false, "error": {"code": "INSUFFICIENT_CREDITS", "message": "balance below the minimum for this run", "details": {}}}

Error codes

HTTPcodewhat it means, and what to do
400VALIDATION_ERROR The request body was rejected. Note that /estimate will not raise this — see step 4.
401UNAUTHORIZED Missing, malformed or expired bearer token. Mint a new guest token, or sign in again.
402INSUFFICIENT_CREDITS The wallet is below the hold this run needs. A guest token always fails here; a run needs a personal token.
404NOT_FOUND No such path, or no such job id for this subject.
409CONFLICT An Idempotency-Key was reused with a different body. Reuse the key only for a byte-identical retry.
429RATE_LIMITED Back off and retry with a widening delay.
500INTERNAL Server-side fault. Safe to retry with the same idempotency key.
503UNAVAILABLE Capacity or an upstream model is unavailable. Retry after a pause.

The request body

There is no task field and there is no input wrapper. The body of /run, /run-stream and /estimate is the input object, flat, at the top level.

Wrapping it — posting {"input": {...}} — is the failure that costs you a run and looks like a success. The call returns 200, a job is created, credits are charged, and every field is silently hidden from the model, which then writes about nothing in particular. Post the fields at the top level.

fieldtyperequiredmeaning
subjectstringyes, non-empty What the aphorisms are about. A situation reads better than an abstract noun.
stancestringyes, may be "" The position the whole batch should argue. Empty means the batch is free to stay open.
registerstringyes One of wry, austere, warm, provocative.
countnumberyes How many aphorisms: 4, 6 or 8.
movesstringyes "mixed", or exactly one of compression, inversion, definition, concession, measure, absence.
avoidstringyes, may be "" Words, phrasings or ideas to keep out of the batch.
crowded_countnumberyes How many corpus sayings already touch this subject. The page computes it in the browser before the run; over the API you supply it.
crowding_notestringyes, may be "" A plain-language reading of that number, handed to the model as context.

Every string field must be present and be a string; every number field must be a finite number. A missing key is not the same as an empty string, and the browser app refuses to send either mistake. The worked example below is used verbatim by every code sample on this page.

{ "subject": "keeping a promise you made when things were easier", "stance": "that keeping it is a different act from making it", "register": "wry", "count": 5, "moves": "mixed", "avoid": "loyalty, integrity, character", "crowded_count": 52, "crowding_note": "52 sayings in the reference corpus touch this subject. This is heavily worked ground - anything that comes easily has almost certainly been said." }

The reply

The model answers in plain text. Not JSON, not Markdown, no code fences. It is three header fields, then one block per aphorism fenced by lines containing only two dashes, then an optional closing note. Consecutive blocks share the separator line between them, so a batch of six has seven separator lines and not twelve.

SUBJECT: <subject as understood> REGISTER: <the register asked for> STANCE: <the position the batch takes, or the word: open> -- LINE: <the aphorism> MOVE: <move id or name> WHY: <what makes it work - may run over several lines without repeating a label> -- LINE: <the next aphorism> MOVE: <move id or name> WHY: <...> -- NOTES: <craft caveats, or the reason a batch is short>

A real reply, lightly shortened:

SUBJECT: keeping a promise you made when things were easier REGISTER: wry STANCE: that keeping it is a different act from making it -- LINE: A promise is a loan taken out against a self you have not met. MOVE: definition WHY: It reframes the promise as debt and the future self as the party who pays. The wryness sits in "have not met" - the debtor cannot be consulted, and was never asked. -- LINE: Nobody keeps a promise. They keep paying one. MOVE: inversion WHY: One verb does all the work. Keeping sounds like storage; paying admits a cost that recurs. -- NOTES: Four rather than six. Two drafts restated the first line in other clothes and were cut rather than padded out.

Four rules the parser leans on, and which a reply is expected to honour:

Only WHY and NOTES are documented as running over several lines. An unlabelled, non-empty line is a continuation of whichever of those is currently open.

The two rules that define the app

These are not stylistic preferences. They are the reason the app exists, and both apply to the whole reply — to LINE, MOVE, WHY, STANCE and NOTES alike, not just to the aphorisms.

  1. Nothing is attributed to anybody. Not a real person, not an invented tradition, not a quoted source, not a dash and a plausible name. A quotation with somebody's name on it that they never said is a fake historical record, and it outlives the tool that made it.
  2. Nothing may reproduce a saying that already exists. The browser matches every field of every reply against a corpus of 1,106 sayings already in circulation, using exact, normalised, reordered and distinctive-word-run comparison, and shows you any hit beside the line that triggered it rather than swallowing it.

What that check has actually been measured to do

The figures, stated plainly, because the shape of the gap matters more than the headline:

Read that last pair together. This is a corpus-membership test, not an originality test. It answers one question: is this line, or something close enough to it, in the 1,106 sayings the app ships with? A clean result means no known match was found. It does not mean the line is original, because the corpus is not the set of all sayings ever written and never could be.

Over the API you do not even get that much, since the check runs in the browser. If originality matters to what you are building, run the check yourself — and still treat a clean result as the absence of evidence rather than as evidence of absence.

1. A tiny client

Everything after this reuses one helper: send JSON, read the envelope, raise on ok: false, hand back data. Eighteen lines in most languages, and it removes the two mistakes that cost the most — reading error off a 200, and reading fields off the envelope instead of off data.

2. A token

Every call carries a bearer token. A guest token is free, needs no account, is minted by one unauthenticated call, and is enough for /me and /estimate. Writing a batch is metered, so /run and /run-stream need a personal token, which comes from signing in.

If you would rather not script the sign-in, the token page reads the token this browser already holds for quote-generator, shows you whose it is, and copies it or a ready-made shell export to the clipboard.

3. Who the token belongs to

/me returns exactly three fields: subject_type, subject_id and credits. There is no email, no display name and no id beyond the subject id, so the only test for "signed in" is subject_type === "user". Anything else is a guest, and a guest cannot spend.

4. Pricing a batch

/estimate costs nothing, creates no job, and returns the credit hold a run with this body would place. You are charged for what the run actually consumes, which is normally well under the hold.

The trap, and it is worth acting on. /estimate performs no validation of the request body at all. A bare string, a number, null, [] and a correctly shaped object all return ok: true, with a well-formed hold and a correct model binding. A malformed body and a right one are indistinguishable from the response.

So a successful estimate tells you nothing whatever about whether your input was shaped right, and the model-binding assertion — the check most likely to be mistaken for proof — passes either way. Validate on your side before you send. This app routes every path that spends, /estimate and /run alike, through one mustBeObject() guard that asserts each string field is a string and each number field is a finite number, for exactly this reason.

5. Writing a batch

/run takes the same body and returns as soon as the job is queued — you get a job_id, not the text. Pass an Idempotency-Key header if you may retry: a repeat with the same key and the same body replays the original job instead of charging twice, and the same key with a different body is a 409 CONFLICT.

6. Waiting for it

Poll GET /jobs/{job_id} until status is succeeded or failed; anything else means keep waiting. A second between polls and a three-minute ceiling is what the app itself uses. On success the whole plain-text reply is one string at data.output.output — note the doubled key, which is the field output inside the object output.

7. Streaming

/run-stream sends the same run as server-sent events, so you can show the batch arriving instead of a spinner. Frames are separated by a blank line, each carries an event: name and a single-line JSON data: payload, and the four names that matter are delta, job, done and error.

The delta texts concatenate to exactly the string that arrives in done.output.output, so streaming changes when you see the text and nothing about what it is. An idempotent replay may answer with a plain JSON body rather than an event stream; check the response content type before you start reading frames.

8. Reading the reply

The last step is the one people skip: the reply is text, so you need a parser. It is a small one. Walk the lines; a line that is nothing but two or more dashes closes the open block and opens the next; a line starting LINE:, MOVE: or WHY: sets that field; an unlabelled non-empty line extends whichever field is still open. Header fields (SUBJECT, REGISTER, STANCE) come before the first separator, and NOTES: comes after the last one.

Two details save the awkward cases. A second LINE: while a block is already open starts a new block, so a missing separator loses formatting rather than losing an aphorism. And a block is only worth keeping if it has a LINE or a WHY, which is what stops a trailing separator producing an empty final entry.

Rate, size and retries