← Short Story Generator / API
Get a token

Drive Short Story Generator from your own code

Everything the web app does goes through one public surface. Base URL:

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

Every request carries Authorization: Bearer <token> and Content-Type: application/json. Every response is a JSON envelope: {"data": …} on success and {"error": {"code": "…", "message": "…"}} on failure. Read error.code, not the HTTP status alone.

Short Story Generator runs one task — task is always the literal string "story" — behind two input shapes. write produces a fresh story from a premise. recast produces another story from the same premise, deliberately far from what is already on screen. Premise, genre, tone and length go in; one complete original short story comes out, with a craft panel that makes the construction visible: what the opening promises, where the turn falls, what the ending pays off, and what was deliberately left unresolved.

The part worth reading twice is brief. A short-story generator's characteristic failure is one story in N costumes — the premise changes, the names change, and the machine underneath does not. Asking a model for variety does not fix that, because variety is not a property any single run can check. So variety is the client's job: every run draws an explicit coordinate from a declared space and sends it as brief. The model writes from a position rather than inventing one. If you call this API directly and send the same brief every time, you will get the same story every time, in costumes.

There is no X-App-Slug header, and the run body is not wrapped. The body of /estimate, /run and /run-stream is the input object itself. Wrapping it as {"input": {…}} does not fail loudly — it returns 200 with a job that runs, because the wrapper becomes one opaque field and every one of your fields, task included, is hidden from the model. You get a fluent story about nothing you asked for. Send the object flat.

The envelope

Success. Everything useful is under data:

{"data": {"job_id": "job_01J9…", "status": "succeeded", "charged_credits": 37,
          "output": {"output": "{\"seed\":\"sd_7f3a91\",\"title\":…}"}}}

Failure. A shaped object, not an exception your HTTP library will raise for you:

{"error": {"code": "VALIDATION_ERROR",
           "message": "premise is required",
           "details": {"field": "premise"}}}

Errors

StatusCodeMeaningWhat to do
400 VALIDATION_ERROR The input object failed validation at the run boundary. Read error.details. The common causes are an {"input": …} wrapper, a missing or misspelled task, a shape outside write/recast, an empty premise, or a recast sent with an empty prior.
401 UNAUTHORIZED Missing, expired, or a token minted for a different app. Mint a guest token or sign in again. A cold 401 from /me before any token exists is the normal first response, not a fault.
402 INSUFFICIENT_CREDITS Balance below min_credits for this run. Top up. Call /estimate first — it is free and returns both the hold and the minimum.
404 NOT_FOUND The job id in /jobs/{id} does not exist, or belongs to another subject. Re-read data.job_id from the /run reply. A job id is scoped to the token that created it, so a fresh guest token cannot poll a previous one's job.
429 RATE_LIMITED Too many requests from this subject. Back off and retry. Do not tight-loop the job poller; two seconds between polls is what the web app uses.
5xx INTERNAL The run started and did not complete. Retry with the same Idempotency-Key so a partial charge is not doubled. If a stream died mid-object, keep the bytes: a story that is 90% written is worth repairing rather than discarding.

The shape field

Read this before the rest of the input object, because it decides which of the other fields are required. Exactly one of two values, and it is not optional:

ShapeWhat it doesAlso required
"write" A fresh story from the premise, at the coordinate in brief. Nothing beyond the common fields.
"recast" The same premise again, written deliberately far from what is already on screen. Same output contract, no extra keys in the reply — the difference lives entirely in the input. prior and avoid_openings. See the recast shape.

Both shapes are the same lane and the same price band. Estimate them separately anyway: a body carrying prior and avoid_openings is structurally larger, and the hold is computed from what you actually send. That is what the client's estimate_hold_credits_by_shape is for.

The input object

FieldTypeRequiredWhat it is
taskstringyesAlways "story". The app has one task and one system prompt; the field is what binds the contract, and anything else is a VALIDATION_ERROR.
shapestringyes"write" or "recast", as above.
premisestringyesThe seed situation, in the caller's own words. One or two sentences is the working range. It is a starting position, not a synopsis: the reply is not obliged to end where the premise points.
steerstringnoA free-text nudge, default "". It refines the premise; it does not replace it and it does not override the coordinate. Send "" rather than omitting it, so the two shapes stay byte-comparable.
genreobjectyes{"id","label","pull","conventions":[],"avoid":[]}. pull is the one sentence saying what this genre wants from a story; conventions are the moves it is entitled to; avoid is its own cliche list, carried in-band so the constraint travels with the request.
toneobjectyes{"id","label","pull","avoid":[],"overrides"}. overrides is the tie-break sentence for where tone and genre disagree — deadpan in a horror story is a real instruction, not a contradiction to be averaged away.
lengthobjectyes{"id","label","target_words","band":[lo,hi],"movements"}. band is the accepted range and target_words the aim inside it; movements is how many beats the story is built in. A length is a structural instruction, not a word budget bolted on afterwards.
briefobjectyesThe coordinate. {"seed","engine":[],"telling":[],"still_near"}. See below — this is the field that makes two runs on one premise two different stories.
factsobjectyes{"words","has_want","has_obstacle","named_entities":[],"notes":[]}. What the client measured about the premise before sending it. Cheap, local, and deliberately in-band: it tells the model what it is actually starting from rather than letting it assume a fully formed situation.
priorarrayon recast[{"title","opening","craft"}] — a digest of each story already on screen for this premise. opening is the first ~40 words, not the whole story.
avoid_openingsarrayon recastOpening content-word pairs already used. Strings, extracted by the client from the openings above.
retry_notestringnoPresent only on a reformat retry. It restates the part of the contract the previous reply broke. It is never treated as premise content — if you find yourself putting story direction here, it belongs in steer.

Worked input: shape: "write"

The whole body. This is what goes to /estimate, /run and /run-stream unchanged and unwrapped.

{
  "task": "story",
  "shape": "write",
  "premise": "A night-shift locksmith is called to open a door he installed himself.",
  "steer": "",
  "genre": {
    "id": "noir",
    "label": "Crime / noir",
    "pull": "Someone is compromised before the story starts, and the plot is the bill arriving.",
    "conventions": ["a debt or a favour standing in for a motive",
                    "competence shown through procedure, not statement",
                    "an ending that settles the case and not the person"],
    "avoid": ["rain-slicked streets as an establishing shot",
              "a femme fatale whose only trait is being a warning",
              "the detective's drink as characterisation"]
  },
  "tone": {
    "id": "deadpan",
    "label": "Deadpan",
    "pull": "Report the extraordinary in the register of the ordinary and let the gap do the work.",
    "avoid": ["winking at the reader", "exclamation as emphasis",
              "a narrator who explains the joke"],
    "overrides": "Where the genre wants heightened language, the tone wins: keep the sentence flat and let the fact be the pressure."
  },
  "length": {
    "id": "short",
    "label": "Short",
    "target_words": 800,
    "band": [650, 1000],
    "movements": 3
  },
  "brief": {
    "seed": "sd_7f3a91",
    "engine": [
      {"axis": "pressure", "label": "The pressure",
       "value": "a fact has surfaced that cannot be put back under"}
    ],
    "telling": [
      {"axis": "entry", "label": "Entry angle",
       "value": "open on an action already in progress"}
    ],
    "still_near": null
  },
  "facts": {
    "words": 14,
    "has_want": true,
    "has_obstacle": false,
    "named_entities": [],
    "notes": ["no proper nouns; the occupation is doing the work of a name",
              "the obstacle is implied by the door, never stated"]
  }
}

brief: the coordinate

engine axes are what the story is — the pressure, the bind, the cost. Two stories with the same engine are one story even when every noun differs. telling axes are how it reaches the page — entry angle, where the turn sits, narrative distance, chronology. Two stories with the same telling and different engines are two stories that sound alike, which is the subtler failure and the more common one.

Each entry is {"axis","label","value"}. The value is a specification of a position, written to be worked from. It is not a phrase to place in the output, and the client checks the finished story for these strings verbatim: if one appears, the model transcribed its brief instead of writing from it. seed is an opaque string you choose; it is echoed back unchanged in the reply and is how you match a story to the coordinate that produced it. still_near is null on a first run, and on a recast names the axis the new draw could not get far enough from — an honest declaration that this run is closer to a prior story than you would like, not a failure.

{
  "seed": "sd_7f3a91",
  "engine": [
    {"axis": "pressure",  "label": "The pressure",
     "value": "a fact has surfaced that cannot be put back under"},
    {"axis": "bind",      "label": "The bind",
     "value": "both available options cost the same person something"}
  ],
  "telling": [
    {"axis": "entry",     "label": "Entry angle",
     "value": "open on an action already in progress"},
    {"axis": "turn",      "label": "Where the turn falls",
     "value": "late, so most of the story is the approach"},
    {"axis": "distance",  "label": "Narrative distance",
     "value": "close third, no access to anyone else's interior"}
  ],
  "still_near": {"axis": "entry", "of": "sd_4b12c0"}
}

facts: what the client measured

Computed locally from the premise, before any request. words is the premise word count; has_want and has_obstacle record whether the premise already supplies a desire and something in its way; named_entities are the proper nouns you found; notes are short observations. A premise with has_obstacle: false is not a bad premise — it tells the model the obstacle is its to invent, instead of leaving it to guess whether one was implied.

{
  "words": 14,
  "has_want": true,
  "has_obstacle": false,
  "named_entities": ["Halloran"],
  "notes": ["the occupation is doing the work of a name",
            "the obstacle is implied by the door, never stated"]
}

The output contract

One JSON object, nothing else — no prose around it, no code fence. It arrives as a JSON string at data.output.output, so it needs a second parse.

{
  "seed": "sd_7f3a91",
  "title": "Short title, 1-6 words, no full stop",
  "story": "Full prose. Paragraphs separated by a blank line (\n\n). No headings, no scene numbers, no markdown.",
  "craft": {
    "promise":    "What the opening promises the reader - the contract the first paragraph signs.",
    "turn":       "Where the turn falls and what actually turns.",
    "payoff":     "What the ending pays off, and which earlier detail pays it.",
    "unresolved": "What is deliberately left open - or an honest statement that the story closes."
  },
  "word_count": 812
}
KeyTypeWhat it holds
seedstringThe brief.seed you sent, echoed unchanged. If it comes back altered, the reply is not about your request.
titlestringOne to six words, no full stop.
storystringThe whole story as prose. Non-empty. Paragraphs split on blank lines — there are no headings, no scene numbers and no markdown to render.
craftobjectExactly four keys — promise, turn, payoff, unresolved — each a non-empty string.
word_countintegerThe model's count of story. Recompute it yourself.

Hard rules the renderer enforces. A reply breaking any of them is retried once, with the broken part restated in retry_note:

  1. Exactly one JSON object, and seed echoed unchanged.
  2. story is a non-empty string. Paragraphs split on blank lines.
  3. craft carries all four keys, each a non-empty string. A missing key is not recoverable by inference — the panel is the audit, so a fabricated entry would be worse than an absent one.
  4. Craft notes never name the genre or the tone. A note that says "as noir demands" compromises every blind read of the output, which was measured on a sibling app: once the panel names the register, no reader can judge whether the prose earned it. Reject the reply if a craft string contains genre.label, genre.id, tone.label or tone.id.
  5. word_count is an integer. The renderer recomputes it from story and shows both when they differ, rather than trusting either one.

Nothing in the contract asks the model to score its own story, and nothing in the reply claims the story is good. The craft panel states what was built, so you can check it against the prose yourself. That is a claim you can falsify by reading; a rating would not be.

1. Get a token

Every call needs one. A guest token is minted on demand and is enough for /me and /estimate; writing is metered and wants a personal token, which comes from signing in. The tokens page shows the token this browser already holds, copies it, copies a ready-made shell export, and mints a fresh guest token — no developer console needed. Replace YOUR_TOKEN below with what it gives you.

To mint one from code instead, POST /guest with the app slug. It takes no Authorization header and returns token and guest_id. Keep the guest_id: it is what lets a later sign-in migrate the guest wallet rather than stranding its balance.

Each sample below also sets up the three things the rest of the page assumes: the base URL, the two headers every request carries, and the envelope unwrap. Check error.code before you touch data.

2. Check the session and the balance

GET /me returns exactly three fields: subject_type, subject_id and credits. There is no email and no display name, so the signed-in test is subject_type === "user" — anything else is a guest. A cold UNAUTHORIZED here, before any token has been minted, is the normal first response and not a fault to report.

3. Price it before you run it

POST /estimate is free, starts no job and charges nothing. It returns hold_credits, min_credits, model and model_aliasgpt-terra, which resolves to gpt-5.6-terra. hold_credits is a reservation, not the price: it is computed against the full output cap, and what is actually charged is normally well below it and is reported after the run. Compare your balance against min_credits, not against the hold.

Estimate each shape separately. A recast body carries prior and avoid_openings and is structurally larger than the write body it follows, so its hold is different even though the lane and the price band are the same.

/estimate performs no validation on the body whatsoever. A bare string, null, an empty array and the number 42 all come back successful, with a correct model binding and a plausible hold. There is no failure signal at all — no throw, no 4xx. So a green estimate proves nothing about your input shape, and the only place that can be checked is your side of the wire. Assert that the body is a plain object with task: "story", a valid shape, a non-empty premise and a brief.seed before every spend, and unit-test that assertion by sabotage rather than by code review.

4. Run it and poll

POST /run submits and returns a job. Always send an Idempotency-Key: a retried request with the same key is the same run, so a network blip cannot bill you twice. The web app's key is short-story-generator:<shape>:<seed>:a<attempt> — the shape is in the key because a recast and a write on the same premise are two different runs, the seed identifies the coordinate, and the attempt counter is what lets a deliberate reformat retry through while a duplicate submit is absorbed.

Poll GET /jobs/{id} every couple of seconds until status is succeeded or failed. The story is the JSON string at output.output, so it needs a second parse.

5. Or stream it

POST /run-stream is the same run, the same body and the same Idempotency-Key, delivered as server-sent events. Frames are separated by a blank line and carry a named event:

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

event: delta
data: {"text":"{\"seed\":\"sd_7f3a91\",\"title\":\"The Door He"}

event: delta
data: {"text":" Installed\",\"story\":\"The call came in at"}

event: done
data: {"job_id":"job_01J9...","status":"succeeded","charged_credits":37,"output":{"output":"..."}}

Accumulate the text of every delta frame; that concatenation is the story object, still as a JSON string. The done frame carries charged_credits — the real price, normally well below the hold — and output.output, which is the same string again for callers that did not buffer. An error frame is terminal and carries code, message and job_id. A pending frame in place of done means the run is continuing out of band: stop reading and poll the job id from step 4.

EventPayloadTerminal
jobjob_id, status. The first frame. Keep the id — it is what you poll if the connection drops.no
deltatext: the next slice of the reply. Concatenate in arrival order; slices split mid-token and mid-escape, so never parse one on its own.no
donejob_id, status, charged_credits, output.output.yes
pendingjob_id, status. The run outlived the stream; poll /jobs/{id}.yes
errorcode, message, job_id. Same codes as the table above.yes

On an idempotent replay the server may answer with plain JSON instead of text/event-stream. Check the content-type before you start reading lines, and fall back to the envelope path if it is not an event stream.

Streaming is worth the extra code here for one reason specific to this app: a story is long and the first paragraph is readable long before the last one exists. Render the prose as it arrives if you like, but do not try to show the craft panel until the object closes — it is the last thing in the JSON, and a half-arrived craft is not a shorter panel, it is a wrong one.

6. The recast shape

A recast is the same premise again, written deliberately far from what is already on screen. The output contract does not change — same five keys, same four craft notes — and there is no extra key in the reply. The whole difference lives in the input, and it is your job to build it: send the same premise, a new coordinate in brief, and tell the model what the earlier stories already did.

Two fields are added to the write body:

{
  "task": "story",
  "shape": "recast",
  "premise": "A night-shift locksmith is called to open a door he installed himself.",
  "steer": "",
  "genre":  { "id": "noir",    "label": "Crime / noir", "pull": "...", "conventions": ["..."], "avoid": ["..."] },
  "tone":   { "id": "deadpan", "label": "Deadpan",      "pull": "...", "avoid": ["..."], "overrides": "..." },
  "length": { "id": "short",   "label": "Short",        "target_words": 800, "band": [650, 1000], "movements": 3 },

  "brief": {
    "seed": "sd_c204e8",
    "engine":  [{"axis": "pressure", "label": "The pressure",
                 "value": "a duty has fallen to this person because no one else remained to take it"}],
    "telling": [{"axis": "entry",    "label": "Entry angle",
                 "value": "open on the aftermath and work backwards"}],
    "still_near": null
  },

  "prior": [
    {
      "title": "The Door He Installed",
      "opening": "The call came in at two-forty. The address was one he knew, because he had fitted the lock on that door eleven years ago and had written the date on the inside of the plate, the way he always did.",
      "craft": {
        "promise":    "A locksmith who recognises his own work is about to learn what it was used for.",
        "turn":       "The plate date is wrong by a year, which means someone replaced the lock after him.",
        "payoff":     "He opens the door and does not go in - the earlier refusal to keep records pays off.",
        "unresolved": "Who called it in is never established, and the story does not pretend to know."
      }
    }
  ],
  "avoid_openings": ["call came", "two-forty address", "lock fitted"]
}
FieldTypeWhat it is
priorarray of objectsOne entry per story already on screen for this premise. title, opening — the first ~40 words, not the whole story — and the full craft object from that reply. The craft panel is what makes a prior usable: it says what that story did, so this one can do something else on purpose rather than by chance.
avoid_openingsarray of stringsContent-word pairs drawn from those openings. Pairs, not whole sentences: a model asked to avoid a sentence will reproduce it with two words changed, whereas a model asked to avoid "call came" has to find another way in.
brief.still_nearobject or nullSet it when your own draw could not get far enough from a prior coordinate on some axis: {"axis": "entry", "of": "sd_7f3a91"}. It is a declaration, not an error — a finite space runs out of corners, and saying so is better than pretending the draw was clean.

Do not send the prior story's full text. It is expensive, and it is worse than useless: a model given prose to avoid reliably absorbs its cadence. The opening and the craft panel are enough to say what has already been done, and short enough that they cannot be transcribed.

7. A complete worked example

Everything above, once, end to end: build the body, price it, run it, parse the reply defensively, and check it against the contract before you show it to anyone. A reply arrives as one bare JSON object and mostly arrives that way. Write the parser for the three cases where it does not — a code fence around it, a sentence of preamble before it, and a stream that stopped mid-object:

  1. Strip a leading ```json or ``` and a trailing ```.
  2. Find the first { and scan forward to its matching close brace, tracking string state and backslash escapes so a brace inside the prose does not throw off the depth count. Everything before and after is discarded.
  3. Parse. If the object never closed, the stream was cut: close what is open, drop a trailing comma and any dangling "key": with no value — do not invent one — and re-parse. A story that is 90% written is worth rendering with an honest note rather than discarding.
  4. Validate: seed echoed, story non-empty, all four craft keys present and non-empty, word_count an integer, and no craft note naming the genre or the tone. Mark what fails; do not repair it silently.

A well-formed reply, abridged in story only:

{
  "seed": "sd_7f3a91",
  "title": "The Door He Installed",
  "story": "The call came in at two-forty and the address was one he knew.\n\n[...]\n\nHe put the tools back in the case in the order they came out, which took longer than the job had.",
  "craft": {
    "promise":    "A man who recognises his own work is about to learn what it was used for.",
    "turn":       "The date stamped inside the plate is a year wrong, so someone changed the lock after him.",
    "payoff":     "He opens the door and does not go in; the refusal to keep records earlier is what buys him that.",
    "unresolved": "Who made the call is never established, and the ending does not pretend to know."
  },
  "word_count": 812
}

Note what is not in the craft notes: the words "noir" and "deadpan" do not appear. That is the rule from the output contract, and it is worth enforcing on your side too. A panel that names its own register tells you the model knew the label; a panel that describes the construction lets you check whether the prose earned it.

Where to go next

Get a token if you have not already, then run the write body from this page unchanged and read what comes back against the output contract. After that, the only interesting question left is the one this API cannot answer for you: where your coordinates come from. Draw them badly — the same engine every time, the same entry angle — and the model will write you the same story with new nouns, exactly as fluently. The endpoints are the easy half.