← Haiku Generator / API
Your token

Driving Haiku Generator from your own code

Haiku Generator is a SkillSafe app, so everything the web page does is available over HTTP. There is one endpoint that matters — a run — and one JSON contract in each direction. This page documents both exactly as app.js implements them, not as they were once intended.

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

One thing the API cannot give you: the syllable counting. That runs in the browser, in syllable.js, and it is what turns the model's claim that a line is five syllables into a checked fact. Over the API you get the model's own syllables array, and you should treat it the way this app does — as a claim to verify, not a measurement.

The envelope

Every response is {"data": ...} on success or {"error": ...} on failure.

{"data": {"job_id": "job_...", "status": "succeeded", "charged_credits": 812, "output": {"output": "{...}"}}}

{"error": {"code": "INSUFFICIENT_CREDITS", "message": "balance below the minimum for this run"}}

Error codes you will actually meet

HTTPcodeWhat to do
401UNAUTHORIZEDThe token is missing, expired or from another app. Get a fresh one — see your token.
402INSUFFICIENT_CREDITSThe balance is under min_credits. Call /estimate first and you will never see this.
403FORBIDDENA guest token tried to run. Runs need a personal token; /me and /estimate do not.
400VALIDATION_ERRORThe input object was malformed. text is the only required field.
429RATE_LIMITEDBack off and retry. Do not tight-loop.
200— with "truncated": trueThe balance sat between min_credits and hold_credits, so the run executed with a reduced output cap. Some poems will be missing. Top up and re-run.

The input contract

There is one input shape, not a lane router. text is the work object whether it is a subject line or a draft haiku; a draft simply arrives with a draft object attached.

FieldTypeNotes
textstringRequired. A subject, a mood, a rough line — or the three lines of a draft, newline separated.
formstring"strict" (5-7-5), "modern" (short-long-short, 8–15 total), or "both".
countnumber1 to 6. How many poems.
voicestring"plain", "classical", "lyrical", "stark", "playful".
seasonstring"auto", "spring", "summer", "autumn", "winter", "none".
notesstringOptional. Words to keep, things to avoid, who it is for.
draftobjectOptional. Present when text is the user's own draft: lines, syllables, target, scans, problems. Its presence is what makes the model revise rather than invent, and what makes changed come back populated.
syllable_factsobjectWhat the client-side counter makes of the input. The web app always sends it; over the API you can send a minimal version or omit it. Sending it measurably improves how often the model hits the count.
retry_notestringOptional. Send on a reformat retry, naming exactly what was wrong with the previous reply.

The output contract

The model returns one JSON object as a string in data.output.output. Parse it, then read:

{
  "title": "Late August, fire escape",
  "reading": "One sentence on how the subject was read.",
  "form": "strict",
  "poems": [
    {
      "id": "H-01",
      "form": "strict",
      "lines": ["...", "...", "..."],
      "syllables": [5, 7, 5],
      "season_word": "late August",
      "cut_after_line": 1,
      "note": "One sentence on the two images and the turn.",
      "changed": []
    }
  ],
  "craft_notes": ["..."],
  "set_aside": [{"line": "...", "why": "..."}],
  "summary": "..."
}

Two fields carry warnings. syllables is the model's own count and is wrong often enough to be worth checking — the web app recounts every line and shows the disagreement. changed is empty unless you sent a draft; if you sent one and it comes back empty, the model ignored the draft and you should retry with a retry_note saying so.

1. Get a token

Open the token page in a browser: it shows the token this browser holds, offers a shell export to copy, and can mint a fresh guest token. A guest token is enough for /me and /estimate; a run needs a personal token, which comes from signing in. Keep it in an environment variable — the examples below read SKILLSAFE_APP_TOKEN, and where a language cannot, they use a literal "YOUR_TOKEN" placeholder you should replace.

2. Check who you are

GET /me returns exactly three fields: subject_type, subject_id and credits. There is no email and no name, so the test for "signed in" is subject_type === "user".

{"data": {"subject_type": "user", "subject_id": "usr_...", "credits": 184920}}

3. Price the run — free, no job

POST /estimate costs nothing and creates nothing. It returns hold_credits (what is reserved, priced against the full output cap), min_credits (below which the run will not start), the resolved model, the model_alias and markup_bps. Compare hold_credits against your balance before you run and you will never meet a 402.

{"data": {"model": "gpt-5.6-terra", "model_alias": "gpt-terra", "markup_bps": 1000,
          "hold_credits": 3120, "min_credits": 240, "sponsor_enabled": false}}

The hold is not the price. You are charged charged_credits from the run result, which is usually a fraction of the hold because a set of haiku is short.

4. Run it, and poll

POST /run returns a job. Always send an Idempotency-Key: it is what stops a network blip from billing you twice, and what makes a retry replay rather than re-run. Derive it from a hash of the input plus an attempt counter, exactly as the web app does.

Then poll GET /jobs/{job_id} until status is succeeded or failed.

5. Or stream it

POST /run-stream returns Server-Sent Events: delta as the text generates, job once accepted, and done at the end carrying charged_credits and the full output. This is what the web page uses, because it is what makes the progress card mean anything.

event: job
data: {"job_id":"job_01J8XYZ"}

event: delta
data: {"text":"{\"title\": \"Late August"}

event: delta
data: {"text":", fire escape\","}

event: done
data: {"job_id":"job_01J8XYZ","status":"succeeded","charged_credits":812,"output":{"output":"{ ... }"}}

If the stream dies mid-flight, keep what arrived. The web app parses the partial JSON and renders every poem that completed rather than discarding a run you paid for.

6. Worked example: fixing a draft

The same endpoint, with a draft object attached. This is the shape to send when the user already wrote something and wants it to scan — the poems come back keeping their images, with changed naming every alteration.

{
  "title": "Grandmother's kitchen, winter",
  "form": "strict",
  "poems": [
    {
      "id": "H-01",
      "form": "strict",
      "lines": [
        "grandmother's kitchen",
        "the kettle whistles alone",
        "the window fogs white"
      ],
      "syllables": [
        5,
        7,
        5
      ],
      "season_word": "winter",
      "cut_after_line": 1,
      "note": "The empty room is set against the kettle nobody has taken off the heat.",
      "changed": [
        "line 1: dropped 'my' and 'in winter', which were the five syllables over",
        "line 2: 'quietly' became 'alone', keeping the stillness and fitting the seven",
        "line 3: added 'white' to close the count and give the fog a colour"
      ]
    }
  ],
  "craft_notes": [
    "The draft explained the kitchen; the revisions let one object stand for it."
  ],
  "set_aside": [
    {
      "line": "the kettle sings to no one",
      "why": "seven syllables, but it names the feeling instead of showing it"
    }
  ],
  "summary": "Three routes to 5-7-5, all keeping the kettle and the fogged window."
}

Rate limits and the things that will bite you