← Fortune Cookie Generator / API
Manage your token

Drive Fortune Cookie Generator from your own code

Everything the web page does is available over HTTP. The base URL is https://api.skillsafe.ai/v1/app-api, authentication is a bearer token, and every response uses the same envelope.

Pick a language once — the choice applies to every sample on the page and is remembered.

The envelope

Every response, success or failure, has this shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "UNAUTHORIZED", "message": "..." } }

Read data on success and error.code on failure. Check the HTTP status too: a 402 carries a well-formed error body.

Authentication, and the header that does not exist

One header: Authorization: Bearer <token>. The token is bound to this app, which is why nothing else needs to identify it.

There is no X-App-Slug header on any endpoint. If you have seen one documented, it was wrong. The single call that names the app is POST /guest, and it names it in the body as {"slug": "fortune-cookie-generator"}.

Errors

StatuscodeWhat it means and what to do
400VALIDATION_ERRORThe body was not acceptable. Note that /estimate does NOT validate - it accepts anything - so this comes from /run.
401UNAUTHORIZEDMissing, malformed or expired token. Mint a fresh guest token, or sign the user in again. A cold 401 from /me before any token exists is the correct answer, not a fault.
402INSUFFICIENT_CREDITSThe balance is below min_credits. Compare estimate.hold_credits against /me credits BEFORE submitting; a 402 after submit is a failure of your client, not of the user.
404NOT_FOUNDUnknown path, or a job id that does not belong to this token.
409CONFLICTAn Idempotency-Key replay whose body does not match the original. Reuse a key only for a genuine retry of the same input.
429RATE_LIMITEDBack off and retry; never tight-loop.
500INTERNALRetry once with the SAME Idempotency-Key so the retry cannot double-bill.

The request body

The same object goes to /estimate, /run and /run-stream. These fields are taken from the app's own buildInput(), not from intent.

FieldTypeRequiredWhat it is for
countnumberyes3, 5 or 7. None of these divides the 13 turns or the 11 angles evenly, which is deliberate: it stops covering the device set from being the cheapest way to satisfy the brief.
tonestringyesdry | warm | ominous | absurd.
tone_pullstringyesThe instruction for that tone, written from the writer's vantage.
tone_avoidstring[]yesThe specific ways that tone fails; the last entry is usually the neighbouring tone it collapses into.
tone_overridestringyesThe ONE house rule this tone is REQUIRED to break. This is what makes the tones structurally different rather than four adjectives.
tone_shapeobjectyesMeasured target ranges for the batch average: wpl, clause, evaluative, you. Send it - the app measures the reply against these, and grading output against a spec it never received measures the model's defaults instead.
modestringyesaphorism | prophecy | advice.
mode_pullstringyesWhat kind of sentence to write.
mode_forbidstring[]yesWhat that mode may not contain.
house_rulesobjectyesH1-H8, the craft defaults, keyed by id.
turnsobject[]yes{id, phrasing} - the joint each sentence is built on. More are sent than there are fortunes to write.
anglesobject[]yes{id, phrasing} - where to aim. count + 2 are sent, so no arrangement of the batch can clear the set.
subjectstringnoWhat to aim the batch at. Omit for an unaimed batch.
crowdingobjectno{count, note} - how many corpus lines sit near the subject. The LINES ARE NEVER SENT: showing a writer the sayings it must not reproduce makes reproduction more likely.
avoid_openingsstring[]noOpenings already used, two content words deep. The one field in the input that is a hard constraint.

Guard the shape yourself. /estimate posts your argument as the request body and validates nothing. A bare string, a number, null and [] all return ok: true with a well-formed estimate, a correct model binding and an identical hold_credits. So a passing estimate proves the model binding and tells you nothing whatever about your input shape. Assert it is an object before every spend; the client is the only place this is catchable.

The reply

The model returns labelled lines, not JSON. Three lines per fortune, a blank line between records, and one NUMBERS: line at the end:

FORTUNE: The kettle arrives on Thursday, before the beds do.
TURN: dated_arrival
ANGLE: a_place

FORTUNE: Your spare key turns up in the second box, already labelled.
TURN: already_done
ANGLE: the_thing_itself

NUMBERS: 3 14 22 29 41 57

The format is deliberate. Each line completes on its own, so a stream cut anywhere yields every whole record before the cut — no bracket-balancing recovery needed, and a batch truncated after three fortunes is three fortunes rather than nothing.

Never pad a short batch to the count you asked for. Report the shortfall instead. Padding makes a truncated run indistinguishable from a complete one, which is the whole reason the format is recoverable.

1. A tiny client

Two helpers the rest of the page reuses.

2. A token

A guest token needs no authentication and is the one call that names the app — in the body. For a signed-in user, use the SSO flow in the web app, or manage a token by hand on the token page.

3. Who the token belongs to

GET /me returns exactly three fields: subject_type, subject_id and credits. There is no username, no email and no id; the signed-in test is subject_type === "user".

4. What it will cost

Free, and it creates no job. hold_credits is a reservation priced against the full output cap — a finished run normally charges far less. Show it as reserved, never as the price.

5. Write the fortunes

Send an Idempotency-Key on every run. A network blip or a malformed first reply must never double-bill. If the response carries a job_id, poll GET /jobs/{job_id} until status is succeeded or failed.

6. Streaming, and the wire format nine apps get wrong

POST /run-stream returns server-sent events. Note that EventSource cannot POST and cannot set an Authorization header, so you read the response body yourself.

The wire format is event: plus data:, frames separated by a blank line. There is no {"type":"delta"} envelope, and a delta's text is at .text. Several published samples across this platform describe data: {"type":"delta"}; that format does not exist, and a parser written against it never fires. Event names are job, delta, done, pending and error.

event: job
data: {"job_id":"job_...","status":"running"}

event: delta
data: {"text":"FORTUNE: The kettle arrives on Thursday, before the beds do.\n"}

event: delta
data: {"text":"TURN: dated_arrival\n"}

event: done
data: {"output":"FORTUNE: ...","charged_credits":812,"truncated":false}

7. Reading the reply

A parser in every language. All of them keep whole records and report the shortfall rather than inventing one.

What the app does with the reply, and what you may want to

The web page runs three browser-side checks over every batch before showing it, and you can reproduce any of them from the reply alone:

If you build on this, please keep the distinction the app makes: a corpus hit means the line already exists; a register hit means the line is ordinary. They are different claims and merging them accuses a merely-generic sentence of being a copied one.