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
| Status | Code | Meaning | What 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:
| Shape | What it does | Also 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
| Field | Type | Required | What it is |
|---|---|---|---|
task | string | yes | Always "story". The app has one task and one system prompt; the field is what binds the contract, and anything else is a VALIDATION_ERROR. |
shape | string | yes | "write" or "recast", as above. |
premise | string | yes | The 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. |
steer | string | no | A 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. |
genre | object | yes | {"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. |
tone | object | yes | {"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. |
length | object | yes | {"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. |
brief | object | yes | The coordinate. {"seed","engine":[],"telling":[],"still_near"}. See below — this is the field that makes two runs on one premise two different stories. |
facts | object | yes | {"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. |
prior | array | on 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_openings | array | on recast | Opening content-word pairs already used. Strings, extracted by the client from the openings above. |
retry_note | string | no | Present 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
}
| Key | Type | What it holds |
|---|---|---|
seed | string | The brief.seed you sent, echoed unchanged. If it comes back altered, the reply is not about your request. |
title | string | One to six words, no full stop. |
story | string | The whole story as prose. Non-empty. Paragraphs split on blank lines — there are no headings, no scene numbers and no markdown to render. |
craft | object | Exactly four keys — promise, turn, payoff, unresolved — each a non-empty string. |
word_count | integer | The 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:
- Exactly one JSON object, and
seedechoed unchanged. storyis a non-empty string. Paragraphs split on blank lines.craftcarries 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.- 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.labelortone.id. word_countis an integer. The renderer recomputes it fromstoryand 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.
# Paste the token from /tokens.html and export it once.
export SD_TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"
# Or mint a guest token from here. No Authorization header on this one.
curl -sS -X POST "$BASE/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"short-story-generator"}'
# {"data":{"token":"...","guest_id":"..."}} -> export SD_TOKEN="that token"
# sd METHOD PATH [JSON-BODY] [EXTRA-HEADER]
sd() {
curl -sS -X "$1" "$BASE$2" \
-H "Authorization: Bearer $SD_TOKEN" \
-H "Content-Type: application/json" \
${4:+-H "$4"} \
${3:+-d "$3"}
}
# Envelope unwrap. Everything useful is under .data; a failure is
# {"error":{"code":"...","message":"..."}} and can arrive with a 200-shaped
# body, so read the code rather than trusting the status alone.
unwrap() {
python3 -c 'import sys,json; e=json.load(sys.stdin); sys.exit("API "+e["error"]["code"]+": "+e["error"]["message"]) if e.get("error") else print(json.dumps(e["data"], indent=2))'
}
import json, requests
TOKEN = "YOUR_TOKEN" # from /tokens.html
BASE = "https://api.skillsafe.ai/v1/app-api"
H = {"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json"}
class ApiError(RuntimeError):
def __init__(self, code, message):
super().__init__(f"{code}: {message}")
self.code = code
def call(method, path, body=None, extra=None, timeout=300):
r = requests.request(method, f"{BASE}{path}",
headers=dict(H, **(extra or {})),
json=body, timeout=timeout)
env = r.json()
if "error" in env:
raise ApiError(env["error"]["code"], env["error"]["message"])
return env["data"]
# A guest token instead, if you have not signed in. No Authorization header.
def mint_guest():
g = requests.post(f"{BASE}/guest",
headers={"Content-Type": "application/json"},
json={"slug": "short-story-generator"}, timeout=30).json()["data"]
H["Authorization"] = f"Bearer {g['token']}"
return g["guest_id"] # keep it: it migrates the wallet
const TOKEN = "YOUR_TOKEN"; // from /tokens.html
const BASE = "https://api.skillsafe.ai/v1/app-api";
const H = { Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json" };
async function call(method, path, body, extra) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: { ...H, ...(extra || {}) },
body: body === undefined ? undefined : JSON.stringify(body)
});
const env = await res.json();
if (env.error) {
const err = new Error(`${env.error.code}: ${env.error.message}`);
err.code = env.error.code;
throw err;
}
return env.data;
}
// A guest token instead. This one call carries no Authorization header.
async function mintGuest() {
const g = (await (await fetch(`${BASE}/guest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "short-story-generator" })
})).json()).data;
H.Authorization = `Bearer ${g.token}`;
return g.guest_id; // keep it
}
package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
// The token comes from /tokens.html. Read it from the environment rather than
// pasting it into source: os.Getenv("SKILLSAFE_TOKEN").
func call(method, path string, body []byte, extra map[string]string) (json.RawMessage, error) {
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, _ := http.NewRequest(method, base+path, rdr)
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
req.Header.Set("Content-Type", "application/json")
for k, v := range extra {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env struct {
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if env.Error != nil {
return nil, errors.New(env.Error.Code + ": " + env.Error.Message)
}
return env.Data, nil
}
// Guest token: POST /guest with the slug and no Authorization header. The
// reply is {"data":{"token":"...","guest_id":"..."}}; keep the guest_id.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
// Prefer System.getenv("SKILLSAFE_TOKEN") over a literal in source.
static final String TOKEN = "YOUR_TOKEN"; // from /tokens.html
static final HttpClient HTTP = HttpClient.newHttpClient();
// Returns the raw envelope. Unwrap `data` with whichever JSON library you
// already use, and read `error.code` before trusting a 200.
static String call(String method, String path, String body,
String headerName, String headerValue) throws Exception {
var pub = (body == null)
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body);
var b = HttpRequest.newBuilder()
.uri(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, pub);
if (headerName != null) b = b.header(headerName, headerValue);
return HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString()).body();
}
// Guest token: POST BASE + "/guest" with {"slug":"short-story-generator"} and NO
// Authorization header; take data.token out of the envelope.
require "net/http"
require "json"
TOKEN = "YOUR_TOKEN" # from /tokens.html
BASE = "https://api.skillsafe.ai/v1/app-api"
class ApiError < StandardError; end
def call(method, path, body = nil, extra = {})
uri = URI("#{BASE}#{path}")
klass = method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
extra.each { |k, v| req[k] = v }
req.body = JSON.generate(body) if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) { |h| h.request(req) }
env = JSON.parse(res.body)
raise ApiError, "#{env["error"]["code"]}: #{env["error"]["message"]}" if env["error"]
env["data"]
end
# Guest token: POST "#{BASE}/guest" with {"slug" => "short-story-generator"} and no
# Authorization header. Keep data["guest_id"] as well as data["token"].
<?php
$token = "YOUR_TOKEN"; // from /tokens.html
$base = "https://api.skillsafe.ai/v1/app-api";
function sd_call($method, $path, $body = null, $extra = []) {
global $token, $base;
$ch = curl_init("$base$path");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => array_merge([
"Authorization: Bearer $token",
"Content-Type: application/json",
], $extra),
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$env = json_decode(curl_exec($ch), true);
if (isset($env["error"])) {
throw new RuntimeException($env["error"]["code"] . ": " . $env["error"]["message"]);
}
return $env["data"];
}
// Guest token: POST "$base/guest" with ["slug" => "short-story-generator"] and only a
// Content-Type header. Reuse $guest["token"] as $token above.
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var token = "YOUR_TOKEN"; // from /tokens.html
var baseUrl = "https://api.skillsafe.ai/v1/app-api";
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
async Task<JsonElement> Call(HttpMethod method, string path, string? body = null,
string? headerName = null, string? headerValue = null) {
var msg = new HttpRequestMessage(method, baseUrl + path);
if (body != null) msg.Content = new StringContent(body, Encoding.UTF8, "application/json");
if (headerName != null) msg.Headers.Add(headerName, headerValue);
var res = await http.SendAsync(msg);
var root = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (root.TryGetProperty("error", out var err))
throw new Exception(err.GetProperty("code").GetString() + ": " +
err.GetProperty("message").GetString());
return root.GetProperty("data");
}
// Guest token: POST baseUrl + "/guest" with {"slug":"short-story-generator"} on a
// deliberately unauthenticated HttpClient; keep data.guest_id.
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.
sd GET /me | unwrap
# {"subject_type":"user","subject_id":"...","credits":1840}
# subject_type is "user" when signed in, and something else for a guest.
me = call("GET", "/me")
signed_in = me["subject_type"] == "user"
print(me["subject_type"], me["credits"], "signed in" if signed_in else "guest")
const me = await call("GET", "/me");
const signedIn = me.subject_type === "user";
console.log(me.subject_type, me.credits, signedIn ? "signed in" : "guest");
raw, err := call("GET", "/me", nil, nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits float64 `json:"credits"`
}
json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits, me.SubjectType == "user")
var body = call("GET", "/me", null, null, null);
// {"data":{"subject_type":"user","subject_id":"...","credits":1840}}
// Only those three fields exist; the signed-in test is subject_type "user".
System.out.println(body);
me = call("GET", "/me")
signed_in = me["subject_type"] == "user"
puts [me["subject_type"], me["credits"], signed_in ? "signed in" : "guest"].join(" ")
<?php
$me = sd_call("GET", "/me");
$signedIn = $me["subject_type"] === "user";
echo $me["subject_type"], " ", $me["credits"], " ", $signedIn ? "signed in" : "guest", "\n";
var me = await Call(HttpMethod.Get, "/me");
var subjectType = me.GetProperty("subject_type").GetString();
var credits = me.GetProperty("credits").GetDouble();
Console.WriteLine($"{subjectType} {credits} {(subjectType == "user" ? "signed in" : "guest")}");
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_alias — gpt-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.
# Put the worked "write" body from above in input.json, then check its shape
# locally, because the endpoint will not check it for you.
python3 - input.json <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
assert isinstance(d, dict), "the body must be an object, not a wrapper or a list"
assert "input" not in d, "do not wrap the body in {\"input\": ...}"
assert d.get("task") == "story", "task must be the literal string story"
assert d.get("shape") in ("write", "recast"), "bad shape"
assert (d.get("premise") or "").strip(), "premise must not be empty"
assert (d.get("brief") or {}).get("seed"), "brief.seed is what the reply echoes"
PY
sd POST /estimate "$(cat input.json)" | unwrap
# {"hold_credits":118,"min_credits":12,"model":"gpt-5.6-terra","model_alias":"gpt-terra"}
SHAPES = ("write", "recast")
def must_be_valid(p):
"""/estimate accepts anything, so the gate has to live here."""
assert isinstance(p, dict), "the body must be an object"
assert "input" not in p, 'do not wrap the body in {"input": ...}'
assert p.get("task") == "story", "task must be the literal string story"
assert p.get("shape") in SHAPES, f"shape must be one of {SHAPES}"
assert (p.get("premise") or "").strip(), "premise must not be empty"
assert (p.get("brief") or {}).get("seed"), "brief.seed is echoed in the reply"
if p["shape"] == "recast":
assert p.get("prior"), "recast needs at least one prior story"
return p
est = call("POST", "/estimate", must_be_valid(payload))
print(est["hold_credits"], est["min_credits"], est["model_alias"])
me = call("GET", "/me")
if me["credits"] < est["min_credits"]:
raise SystemExit("top up first - the hold is not the price, but the minimum is real")
const SHAPES = ["write", "recast"];
// /estimate accepts anything, so the gate has to live here.
function mustBeValid(p) {
if (!p || typeof p !== "object" || Array.isArray(p)) throw new Error("body must be an object");
if ("input" in p) throw new Error('do not wrap the body in {"input": ...}');
if (p.task !== "story") throw new Error("task must be the literal string story");
if (!SHAPES.includes(p.shape)) throw new Error(`shape must be one of ${SHAPES}`);
if (!String(p.premise || "").trim()) throw new Error("premise must not be empty");
if (!(p.brief && p.brief.seed)) throw new Error("brief.seed is echoed in the reply");
if (p.shape === "recast" && !(p.prior || []).length) throw new Error("recast needs a prior");
return p;
}
const est = await call("POST", "/estimate", mustBeValid(payload));
console.log(est.hold_credits, est.min_credits, est.model_alias);
const me = await call("GET", "/me");
if (me.credits < est.min_credits) throw new Error("top up first");
// payload is the worked "write" body, marshalled from your own struct - and
// validated before it leaves, because /estimate will accept anything at all.
raw, err := call("POST", "/estimate", payload, nil)
if err != nil {
panic(err)
}
var est struct {
HoldCredits float64 `json:"hold_credits"`
MinCredits float64 `json:"min_credits"`
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
}
json.Unmarshal(raw, &est)
fmt.Println(est.HoldCredits, est.MinCredits, est.ModelAlias)
// The hold is a reservation against the output cap. Compare the balance from
// /me against est.MinCredits, never against est.HoldCredits.
// payload is the worked "write" body as a JSON string. Validate it here:
// task == "story", shape in {write, recast}, premise non-empty, brief.seed
// present. /estimate returns a plausible number for a malformed body.
var est = call("POST", "/estimate", payload, null, null);
// {"data":{"hold_credits":118,"min_credits":12,
// "model":"gpt-5.6-terra","model_alias":"gpt-terra"}}
System.out.println(est);
// Compare the balance from /me against min_credits, not against hold_credits.
SHAPES = %w[write recast].freeze
def must_be_valid(p)
raise "the body must be an object" unless p.is_a?(Hash)
raise 'do not wrap the body in input' if p.key?("input")
raise "task must be story" unless p["task"] == "story"
raise "shape must be one of #{SHAPES}" unless SHAPES.include?(p["shape"])
raise "premise must not be empty" if p["premise"].to_s.strip.empty?
raise "brief.seed is echoed back" if p.dig("brief", "seed").to_s.empty?
raise "recast needs a prior" if p["shape"] == "recast" && Array(p["prior"]).empty?
p
end
est = call("POST", "/estimate", must_be_valid(payload))
puts est["hold_credits"], est["min_credits"], est["model_alias"]
me = call("GET", "/me")
abort "top up first" if me["credits"] < est["min_credits"]
<?php
function must_be_valid(array $p): array {
if (array_key_exists("input", $p)) throw new RuntimeException("do not wrap the body");
if (($p["task"] ?? null) !== "story") throw new RuntimeException("task must be story");
if (!in_array($p["shape"] ?? "", ["write", "recast"], true)) {
throw new RuntimeException("bad shape");
}
if (trim($p["premise"] ?? "") === "") throw new RuntimeException("premise is empty");
if (($p["brief"]["seed"] ?? "") === "") throw new RuntimeException("brief.seed missing");
return $p;
}
$est = sd_call("POST", "/estimate", must_be_valid($payload));
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model_alias"], "\n";
$me = sd_call("GET", "/me");
if ($me["credits"] < $est["min_credits"]) {
throw new RuntimeException("top up first");
}
// payload is the worked "write" body as a JSON string, validated on this side
// of the wire: /estimate returns a plausible hold for a malformed body.
var est = await Call(HttpMethod.Post, "/estimate", payload);
var hold = est.GetProperty("hold_credits").GetDouble();
var min = est.GetProperty("min_credits").GetDouble();
Console.WriteLine($"{hold} {min} {est.GetProperty("model_alias").GetString()}");
var me = await Call(HttpMethod.Get, "/me");
if (me.GetProperty("credits").GetDouble() < min)
throw new Exception("top up first - the hold is not the price, but the minimum is real");
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.
# Submit. The body IS the input object - there is no {"input": ...} wrapper
# and there is no X-App-Slug header.
KEY="short-story-generator:write:sd_7f3a91:a1"
JOB=$(sd POST /run "$(cat input.json)" "Idempotency-Key: $KEY" \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["job_id"])')
# Poll until terminal. Two seconds; do not tight-loop.
while true; do
S=$(sd GET "/jobs/$JOB")
echo "$S" | grep -q '"status":"succeeded"' && break
echo "$S" | grep -q '"status":"failed"' && { echo "$S"; exit 1; }
sleep 2
done
# The story object is a JSON string at .data.output.output - parse it again.
echo "$S" | python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["output"]["output"])'
import time
key = "short-story-generator:write:sd_7f3a91:a1"
job = call("POST", "/run", must_be_valid(payload), {"Idempotency-Key": key})
while job["status"] in ("queued", "running"):
time.sleep(2)
job = call("GET", f"/jobs/{job['job_id']}")
if job["status"] == "failed":
raise RuntimeError(job.get("error") or "the run did not complete")
story = json.loads(job["output"]["output"]) # a JSON string, parsed again
print(story["title"], story["word_count"], "credits:", job.get("charged_credits"))
print(story["craft"]["promise"])
print(story["story"].split("\n\n")[0]) # first paragraph
const key = "short-story-generator:write:sd_7f3a91:a1";
let job = await call("POST", "/run", mustBeValid(payload), { "Idempotency-Key": key });
while (job.status === "queued" || job.status === "running") {
await new Promise(r => setTimeout(r, 2000));
job = await call("GET", `/jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(job.error || "the run did not complete");
const story = JSON.parse(job.output.output); // a JSON string, parsed again
console.log(story.title, story.word_count, job.charged_credits);
console.log(story.craft.turn);
const paragraphs = story.story.split("\n\n");
key := map[string]string{"Idempotency-Key": "short-story-generator:write:sd_7f3a91:a1"}
raw, err := call("POST", "/run", payload, key)
if err != nil {
panic(err)
}
var job struct {
JobID string `json:"job_id"`
Status string `json:"status"`
ChargedCredits float64 `json:"charged_credits"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
json.Unmarshal(raw, &job)
for job.Status == "queued" || job.Status == "running" {
time.Sleep(2 * time.Second)
raw, err = call("GET", "/jobs/"+job.JobID, nil, nil)
if err != nil {
panic(err)
}
json.Unmarshal(raw, &job)
}
// job.Output.Output is a JSON string holding the story; unmarshal it again.
var story struct {
Seed string `json:"seed"`
Title string `json:"title"`
Story string `json:"story"`
Craft map[string]string `json:"craft"`
WordCount int `json:"word_count"`
}
json.Unmarshal([]byte(job.Output.Output), &story)
fmt.Println(story.Title, story.WordCount, story.Craft["payoff"])
var key = "short-story-generator:write:sd_7f3a91:a1";
var submitted = call("POST", "/run", payload, "Idempotency-Key", key);
// Pull data.job_id out of `submitted`, then poll "/jobs/" + jobId every two
// seconds until data.status is "succeeded" or "failed".
String jobBody;
do {
Thread.sleep(2000);
jobBody = call("GET", "/jobs/" + jobId, null, null, null);
} while (jobBody.contains("\"status\":\"queued\"")
|| jobBody.contains("\"status\":\"running\""));
// The story object is the JSON *string* at data.output.output - parse it again
// with your JSON library, then read seed, title, story, craft, word_count.
System.out.println(jobBody);
key = "short-story-generator:write:sd_7f3a91:a1"
job = call("POST", "/run", must_be_valid(payload), { "Idempotency-Key" => key })
while %w[queued running].include?(job["status"])
sleep 2
job = call("GET", "/jobs/#{job["job_id"]}")
end
raise "the run did not complete" if job["status"] == "failed"
story = JSON.parse(job["output"]["output"]) # a JSON string, parsed again
puts story["title"], story["word_count"], job["charged_credits"]
puts story["craft"]["unresolved"]
paragraphs = story["story"].split("\n\n")
<?php
$key = "short-story-generator:write:sd_7f3a91:a1";
$job = sd_call("POST", "/run", must_be_valid($payload), ["Idempotency-Key: $key"]);
while (in_array($job["status"], ["queued", "running"], true)) {
sleep(2);
$job = sd_call("GET", "/jobs/" . rawurlencode($job["job_id"]));
}
if ($job["status"] === "failed") {
throw new RuntimeException("the run did not complete");
}
$story = json_decode($job["output"]["output"], true); // parsed again
echo $story["title"], " ", $story["word_count"], " ", $job["charged_credits"] ?? 0, "\n";
echo $story["craft"]["promise"], "\n";
$paragraphs = explode("\n\n", $story["story"]);
var key = "short-story-generator:write:sd_7f3a91:a1";
var job = await Call(HttpMethod.Post, "/run", payload, "Idempotency-Key", key);
var jobId = job.GetProperty("job_id").GetString();
var status = job.GetProperty("status").GetString();
while (status is "queued" or "running") {
await Task.Delay(2000);
job = await Call(HttpMethod.Get, $"/jobs/{jobId}");
status = job.GetProperty("status").GetString();
}
if (status == "failed") throw new Exception("the run did not complete");
// A JSON string holding the story object - parse it again.
var story = JsonDocument.Parse(
job.GetProperty("output").GetProperty("output").GetString()!).RootElement;
Console.WriteLine(story.GetProperty("title").GetString());
Console.WriteLine(story.GetProperty("craft").GetProperty("turn").GetString());
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.
| Event | Payload | Terminal |
|---|---|---|
job | job_id, status. The first frame. Keep the id — it is what you poll if the connection drops. | no |
delta | text: 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 |
done | job_id, status, charged_credits, output.output. | yes |
pending | job_id, status. The run outlived the stream; poll /jobs/{id}. | yes |
error | code, 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.
curl -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \ -H "Authorization: Bearer $SD_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: short-story-generator:write:sd_7f3a91:a1" \ -d @input.json # Frames arrive as `event: NAME` + `data: JSON`, separated by a blank line. # Concatenate the .text of every `delta`; the `done` frame carries # charged_credits and output.output. A `pending` frame means poll the job id.
out, done = "", None
headers = dict(H, **{"Idempotency-Key": "short-story-generator:write:sd_7f3a91:a1"})
with requests.post(f"{BASE}/run-stream", headers=headers,
json=must_be_valid(payload), stream=True, timeout=900) as r:
event = "message"
for line in r.iter_lines(decode_unicode=True):
if line is None or line == "":
event = "message"
continue
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
evt = json.loads(line[5:].strip())
if event == "delta":
out += evt.get("text", "")
elif event in ("done", "pending"):
done = evt
elif event == "error":
raise ApiError(evt.get("code", "INTERNAL"), evt.get("message", ""))
print(done.get("charged_credits"))
story = json.loads(out) # or poll the job id if this was pending
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { ...H, "Idempotency-Key": "short-story-generator:write:sd_7f3a91:a1" },
body: JSON.stringify(mustBeValid(payload))
});
// An idempotent replay answers with plain JSON instead of a stream.
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const env = await res.json();
return JSON.parse(env.data.output.output);
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", out = "", done = null;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buf += dec.decode(chunk.value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const frame = buf.slice(0, i);
buf = buf.slice(i + 2);
let name = "message", data = "";
for (const l of frame.split("\n")) {
if (l.startsWith("event:")) name = l.slice(6).trim();
else if (l.startsWith("data:")) data += l.slice(5).trim();
}
if (!data) continue;
const evt = JSON.parse(data);
if (name === "delta") out += evt.text || "";
else if (name === "done" || name === "pending") done = evt;
else if (name === "error") throw new Error(`${evt.code}: ${evt.message}`);
}
}
console.log(done.charged_credits);
const story = JSON.parse(out);
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "short-story-generator:write:sd_7f3a91:a1")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var out strings.Builder
name := "message"
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case line == "":
name = "message"
case strings.HasPrefix(line, "event:"):
name = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:") && name == "delta":
var evt struct {
Text string `json:"text"`
}
json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &evt)
out.WriteString(evt.Text)
}
}
var story map[string]any
json.Unmarshal([]byte(out.String()), &story)
var req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "short-story-generator:write:sd_7f3a91:a1")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
var out = new StringBuilder();
var name = new String[]{ "message" };
HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
if (line.isEmpty()) { name[0] = "message"; }
else if (line.startsWith("event:")) { name[0] = line.substring(6).trim(); }
else if (line.startsWith("data:") && name[0].equals("delta")) {
// Parse the frame with your JSON library and append its `text`.
out.append(textOf(line.substring(5).trim()));
}
});
// out now holds the story object as a JSON string; parse it once at the end.
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "short-story-generator:write:sd_7f3a91:a1"
req.body = JSON.generate(must_be_valid(payload))
out = +""
name = "message"
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 900) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.empty? then name = "message"
elsif line.start_with?("event:") then name = line[6..].strip
elsif line.start_with?("data:")
evt = JSON.parse(line[5..].strip)
out << evt["text"].to_s if name == "delta"
end
end
end
end
end
story = JSON.parse(out)
<?php
$out = "";
$name = "message";
$ch = curl_init("$base/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(must_be_valid($payload)),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
"Idempotency-Key: short-story-generator:write:sd_7f3a91:a1",
],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$out, &$name) {
foreach (explode("\n", $chunk) as $line) {
$line = rtrim($line, "\r");
if ($line === "") {
$name = "message";
} elseif (str_starts_with($line, "event:")) {
$name = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:") && $name === "delta") {
$evt = json_decode(trim(substr($line, 5)), true);
$out .= $evt["text"] ?? "";
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
$story = json_decode($out, true);
var msg = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/run-stream") {
Content = new StringContent(payload, Encoding.UTF8, "application/json")
};
msg.Headers.Add("Idempotency-Key", "short-story-generator:write:sd_7f3a91:a1");
var res = await http.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await res.Content.ReadAsStreamAsync());
var sb = new StringBuilder();
var name = "message";
while (await sr.ReadLineAsync() is string line) {
if (line.Length == 0) { name = "message"; }
else if (line.StartsWith("event:")) { name = line[6..].Trim(); }
else if (line.StartsWith("data:") && name == "delta") {
var evt = JsonDocument.Parse(line[5..].Trim()).RootElement;
if (evt.TryGetProperty("text", out var t)) sb.Append(t.GetString());
}
}
var story = JsonDocument.Parse(sb.ToString()).RootElement;
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"]
}
| Field | Type | What it is |
|---|---|---|
prior | array of objects | One 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_openings | array of strings | Content-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_near | object or null | Set 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.
# Build the recast body from the write body and the reply you already have.
# prior.opening is the first 40 words; avoid_openings is content-word pairs.
# Redirect this into recast.json yourself, or pipe it straight into `sd`.
python3 - input.json reply.json <<'PY'
import json, re, sys
body = json.load(open(sys.argv[1]))
reply = json.load(open(sys.argv[2]))
STOP = {"the","a","an","and","of","to","in","on","at","it","he","she","they","was","had"}
words = reply["story"].split()
opening = " ".join(words[:40])
toks = [w for w in re.findall(r"[a-z']+", opening.lower()) if w not in STOP]
body["shape"] = "recast"
body["brief"]["seed"] = "sd_c204e8" # a NEW coordinate, not the old one
body["prior"] = [{"title": reply["title"], "opening": opening, "craft": reply["craft"]}]
body["avoid_openings"] = [" ".join(p) for p in zip(toks, toks[1:])][:8]
print(json.dumps(body, indent=2))
PY
# Then estimate and run it exactly as before, with recast in the key:
# sd POST /run "$(cat recast.json)" "Idempotency-Key: short-story-generator:recast:sd_c204e8:a1"
import re
STOP = {"the", "a", "an", "and", "of", "to", "in", "on", "at",
"it", "he", "she", "they", "was", "had"}
def digest(reply, opening_words=40):
"""A prior entry: title, the first ~40 words, and the craft panel."""
opening = " ".join(reply["story"].split()[:opening_words])
return {"title": reply["title"], "opening": opening, "craft": reply["craft"]}
def opening_pairs(opening, limit=8):
toks = [w for w in re.findall(r"[a-z']+", opening.lower()) if w not in STOP]
return [" ".join(p) for p in zip(toks, toks[1:])][:limit]
def to_recast(body, replies, new_seed, still_near=None):
out = dict(body, shape="recast")
out["brief"] = dict(body["brief"], seed=new_seed, still_near=still_near)
out["prior"] = [digest(r) for r in replies]
out["avoid_openings"] = sorted({p for d in out["prior"]
for p in opening_pairs(d["opening"])})
return out
recast = to_recast(payload, [story], "sd_c204e8")
job = call("POST", "/run", must_be_valid(recast),
{"Idempotency-Key": "short-story-generator:recast:sd_c204e8:a1"})
const STOP = new Set(["the", "a", "an", "and", "of", "to", "in", "on", "at",
"it", "he", "she", "they", "was", "had"]);
const digest = (reply) => ({
title: reply.title,
opening: reply.story.split(/\s+/).slice(0, 40).join(" "),
craft: reply.craft
});
function openingPairs(opening, limit = 8) {
const toks = (opening.toLowerCase().match(/[a-z']+/g) || [])
.filter(w => !STOP.has(w));
return toks.slice(0, -1).map((w, i) => `${w} ${toks[i + 1]}`).slice(0, limit);
}
function toRecast(body, replies, newSeed, stillNear = null) {
const prior = replies.map(digest);
return {
...body,
shape: "recast",
brief: { ...body.brief, seed: newSeed, still_near: stillNear },
prior,
avoid_openings: [...new Set(prior.flatMap(d => openingPairs(d.opening)))].sort()
};
}
const recast = toRecast(payload, [story], "sd_c204e8");
const job = await call("POST", "/run", mustBeValid(recast),
{ "Idempotency-Key": "short-story-generator:recast:sd_c204e8:a1" });
type Prior struct {
Title string `json:"title"`
Opening string `json:"opening"`
Craft map[string]string `json:"craft"`
}
// The first 40 words of the previous story, plus its craft panel. Never the
// whole story: prose handed over to be avoided gets absorbed, not avoided.
func digest(title, prose string, craft map[string]string) Prior {
w := strings.Fields(prose)
if len(w) > 40 {
w = w[:40]
}
return Prior{Title: title, Opening: strings.Join(w, " "), Craft: craft}
}
// Then, on your own request struct:
// body.Shape = "recast"
// body.Brief.Seed = "sd_c204e8" // a NEW coordinate
// body.Prior = []Prior{digest(...)}
// body.AvoidOpenings = pairsFrom(prior.Opening)
//
// and submit it with an Idempotency-Key naming the shape and the new seed:
key := map[string]string{"Idempotency-Key": "short-story-generator:recast:sd_c204e8:a1"}
raw, err := call("POST", "/run", recastBody, key)
// A prior entry is {title, opening, craft} where opening is the first ~40
// words of the previous story. Build it from the reply you already parsed:
var words = previousStory.split("\\s+");
var opening = String.join(" ",
java.util.Arrays.copyOfRange(words, 0, Math.min(40, words.length)));
// avoid_openings holds content-word PAIRS taken from that opening - pairs,
// because a whole sentence handed over to be avoided comes back with two
// words changed, while "call came" forces a different way in.
// Set shape to "recast", give brief a NEW seed, attach prior and
// avoid_openings, then submit with the shape and seed in the key:
var key = "short-story-generator:recast:sd_c204e8:a1";
var submitted = call("POST", "/run", recastPayload, "Idempotency-Key", key);
STOP = %w[the a an and of to in on at it he she they was had].freeze
def digest(reply, opening_words = 40)
{ "title" => reply["title"],
"opening" => reply["story"].split[0, opening_words].join(" "),
"craft" => reply["craft"] }
end
def opening_pairs(opening, limit = 8)
toks = opening.downcase.scan(/[a-z']+/).reject { |w| STOP.include?(w) }
toks.each_cons(2).map { |a, b| "#{a} #{b}" }.first(limit)
end
def to_recast(body, replies, new_seed, still_near = nil)
out = body.merge("shape" => "recast")
out["brief"] = body["brief"].merge("seed" => new_seed, "still_near" => still_near)
out["prior"] = replies.map { |r| digest(r) }
out["avoid_openings"] = out["prior"].flat_map { |d| opening_pairs(d["opening"]) }.uniq.sort
out
end
recast = to_recast(payload, [story], "sd_c204e8")
job = call("POST", "/run", must_be_valid(recast),
{ "Idempotency-Key" => "short-story-generator:recast:sd_c204e8:a1" })
<?php
const STOP = ["the","a","an","and","of","to","in","on","at","it","he","she","they","was","had"];
function digest(array $reply, int $openingWords = 40): array {
$words = preg_split('/\s+/', trim($reply["story"]));
return [
"title" => $reply["title"],
"opening" => implode(" ", array_slice($words, 0, $openingWords)),
"craft" => $reply["craft"],
];
}
function opening_pairs(string $opening, int $limit = 8): array {
preg_match_all("/[a-z']+/", strtolower($opening), $m);
$toks = array_values(array_diff($m[0], STOP));
$pairs = [];
for ($i = 0; $i + 1 < count($toks) && count($pairs) < $limit; $i++) {
$pairs[] = $toks[$i] . " " . $toks[$i + 1];
}
return $pairs;
}
$recast = $payload;
$recast["shape"] = "recast";
$recast["brief"]["seed"] = "sd_c204e8"; // a NEW coordinate
$recast["prior"] = [digest($story)];
$recast["avoid_openings"] = opening_pairs($recast["prior"][0]["opening"]);
$job = sd_call("POST", "/run", must_be_valid($recast),
["Idempotency-Key: short-story-generator:recast:sd_c204e8:a1"]);
static readonly HashSet<string> Stop = new() {
"the","a","an","and","of","to","in","on","at","it","he","she","they","was","had"
};
// A prior entry is {title, opening, craft}; opening is the first ~40 words.
static string Opening(string prose, int n = 40) =>
string.Join(" ", prose.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Take(n));
static List<string> OpeningPairs(string opening, int limit = 8) {
var toks = System.Text.RegularExpressions.Regex
.Matches(opening.ToLowerInvariant(), "[a-z']+")
.Select(m => m.Value).Where(w => !Stop.Contains(w)).ToList();
return toks.Zip(toks.Skip(1), (a, b) => $"{a} {b}").Take(limit).ToList();
}
// Set shape to "recast", put a NEW seed in brief, attach prior and
// avoid_openings, then submit with the shape and the new seed in the key.
var job = await Call(HttpMethod.Post, "/run", recastPayload,
"Idempotency-Key", "short-story-generator:recast:sd_c204e8:a1");
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:
- Strip a leading
```jsonor```and a trailing```. - 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. - 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. - Validate:
seedechoed,storynon-empty, all fourcraftkeys present and non-empty,word_countan 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.
#!/usr/bin/env bash
# End to end: estimate, run, poll, parse, check. Assumes sd() and unwrap()
# from step 1, and the worked "write" body in input.json.
set -euo pipefail
SEED=$(python3 -c 'import sys,json; print(json.load(open("input.json"))["brief"]["seed"])')
sd POST /estimate "$(cat input.json)" | unwrap # free; charges nothing
sd GET /me | unwrap # credits vs min_credits
JOB=$(sd POST /run "$(cat input.json)" "Idempotency-Key: short-story-generator:write:$SEED:a1" \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["job_id"])')
while true; do
S=$(sd GET "/jobs/$JOB")
echo "$S" | grep -q '"status":"succeeded"' && break
echo "$S" | grep -q '"status":"failed"' && { echo "$S" >&2; exit 1; }
sleep 2
done
echo "$S" | SEED="$SEED" python3 -c '
import json, os, re, sys
job = json.load(sys.stdin)["data"]
raw = job["output"]["output"]
raw = re.sub(r"^```(?:json)?|```$", "", raw.strip()).strip()
raw = raw[raw.index("{"):] # discard any preamble
v = json.loads(raw)
assert v["seed"] == os.environ["SEED"], "seed came back changed"
assert v["story"].strip(), "empty story"
for k in ("promise", "turn", "payoff", "unresolved"):
assert v["craft"].get(k, "").strip(), "craft is missing " + k
assert isinstance(v["word_count"], int), "word_count must be an integer"
print(v["title"])
print("claimed", v["word_count"], "actual", len(v["story"].split()))
print("charged", job.get("charged_credits"))
'
import json, re, time
CRAFT = ("promise", "turn", "payoff", "unresolved")
def first_object(text):
"""The reply is one JSON object. Find it even when it arrives dressed."""
t = re.sub(r"^```(?:json)?|```$", "", text.strip()).strip()
start = t.find("{")
if start < 0:
raise ValueError("no object in the reply")
depth, in_str, esc = 0, False, False
for i, c in enumerate(t[start:], start):
if esc: esc = False; continue
if c == "\\": esc = True; continue
if c == '"': in_str = not in_str; continue
if in_str: continue
if c == "{": depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return t[start:i + 1], True
return t[start:], False # truncated: repair rather than discard
def check(v, sent):
"""Everything the renderer enforces. Report; never repair silently."""
problems = []
if v.get("seed") != sent["brief"]["seed"]: problems.append("seed changed")
if not (v.get("story") or "").strip(): problems.append("empty story")
for k in CRAFT:
if not (v.get("craft") or {}).get(k, "").strip():
problems.append(f"craft.{k} missing")
if not isinstance(v.get("word_count"), int): problems.append("word_count not an int")
labels = {sent["genre"]["label"].lower(), sent["genre"]["id"].lower(),
sent["tone"]["label"].lower(), sent["tone"]["id"].lower()}
for k in CRAFT:
note = (v.get("craft") or {}).get(k, "").lower()
if any(l and l in note for l in labels):
problems.append(f"craft.{k} names the genre or the tone")
return problems
payload = must_be_valid(build_write_body()) # your own builder
est = call("POST", "/estimate", payload)
if call("GET", "/me")["credits"] < est["min_credits"]:
raise SystemExit("top up first")
seed = payload["brief"]["seed"]
job = call("POST", "/run", payload, {"Idempotency-Key": f"short-story-generator:write:{seed}:a1"})
while job["status"] in ("queued", "running"):
time.sleep(2)
job = call("GET", f"/jobs/{job['job_id']}")
if job["status"] == "failed":
raise RuntimeError(job.get("error") or "the run did not complete")
text, closed = first_object(job["output"]["output"])
if not closed:
# The stream was cut. Close what is open and drop a dangling "key": with
# no value - never invent one - then re-parse. A story that is 90%
# written is worth showing with a note; it is not worth faking.
text = re.sub(r',\s*"[^"]*"\s*:\s*$', "", text.rstrip().rstrip(",")) + '"}}'
story = json.loads(text)
for p in check(story, payload):
print("CONTRACT:", p)
print(story["title"], "|", story["word_count"], "claimed,",
len(story["story"].split()), "actual, charged", job.get("charged_credits"))
const CRAFT = ["promise", "turn", "payoff", "unresolved"];
// The reply is one JSON object. Find it even when it arrives dressed in a
// code fence, behind a sentence of preamble, or cut off by a dead stream.
function firstObject(text) {
const t = text.trim().replace(/^```(?:json)?/, "").replace(/```$/, "").trim();
const start = t.indexOf("{");
if (start < 0) throw new Error("no object in the reply");
let depth = 0, inStr = false, esc = false;
for (let i = start; i < t.length; i++) {
const c = t[i];
if (esc) { esc = false; continue; }
if (c === "\\") { esc = true; continue; }
if (c === '"') { inStr = !inStr; continue; }
if (inStr) continue;
if (c === "{") depth++;
else if (c === "}" && --depth === 0) return [t.slice(start, i + 1), true];
}
return [t.slice(start), false]; // truncated
}
function check(v, sent) {
const bad = [];
if (v.seed !== sent.brief.seed) bad.push("seed changed");
if (!String(v.story || "").trim()) bad.push("empty story");
for (const k of CRAFT) {
if (!String((v.craft || {})[k] || "").trim()) bad.push(`craft.${k} missing`);
}
if (!Number.isInteger(v.word_count)) bad.push("word_count not an integer");
const labels = [sent.genre.label, sent.genre.id, sent.tone.label, sent.tone.id]
.filter(Boolean).map(s => s.toLowerCase());
for (const k of CRAFT) {
const note = String((v.craft || {})[k] || "").toLowerCase();
if (labels.some(l => note.includes(l))) bad.push(`craft.${k} names the genre or the tone`);
}
return bad;
}
const payload = mustBeValid(buildWriteBody()); // your own builder
const est = await call("POST", "/estimate", payload);
if ((await call("GET", "/me")).credits < est.min_credits) throw new Error("top up first");
const seed = payload.brief.seed;
let job = await call("POST", "/run", payload,
{ "Idempotency-Key": `short-story-generator:write:${seed}:a1` });
while (job.status === "queued" || job.status === "running") {
await new Promise(r => setTimeout(r, 2000));
job = await call("GET", `/jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(job.error || "the run did not complete");
const [text] = firstObject(job.output.output);
const story = JSON.parse(text);
for (const p of check(story, payload)) console.warn("CONTRACT:", p);
console.log(story.title, story.word_count, story.story.split(/\s+/).length,
job.charged_credits);
var craftKeys = []string{"promise", "turn", "payoff", "unresolved"}
type Story struct {
Seed string `json:"seed"`
Title string `json:"title"`
Story string `json:"story"`
Craft map[string]string `json:"craft"`
WordCount int `json:"word_count"`
}
// One JSON object, found even behind a fence or a sentence of preamble.
func firstObject(t string) (string, bool) {
t = strings.TrimSpace(t)
t = strings.TrimPrefix(strings.TrimPrefix(t, "```json"), "```")
t = strings.TrimSuffix(strings.TrimSpace(t), "```")
start := strings.Index(t, "{")
if start < 0 {
return "", false
}
depth, inStr, esc := 0, false, false
for i := start; i < len(t); i++ {
c := t[i]
switch {
case esc:
esc = false
case c == '\\':
esc = true
case c == '"':
inStr = !inStr
case inStr:
case c == '{':
depth++
case c == '}':
depth--
if depth == 0 {
return t[start : i+1], true
}
}
}
return t[start:], false
}
func check(v Story, seed, genreLabel, toneLabel string) []string {
var bad []string
if v.Seed != seed {
bad = append(bad, "seed changed")
}
if strings.TrimSpace(v.Story) == "" {
bad = append(bad, "empty story")
}
for _, k := range craftKeys {
note := strings.TrimSpace(v.Craft[k])
if note == "" {
bad = append(bad, "craft."+k+" missing")
continue
}
low := strings.ToLower(note)
if strings.Contains(low, strings.ToLower(genreLabel)) ||
strings.Contains(low, strings.ToLower(toneLabel)) {
bad = append(bad, "craft."+k+" names the genre or the tone")
}
}
return bad
}
// estimate -> /me -> run -> poll (steps 3 and 4), then:
text, closed := firstObject(job.Output.Output)
var story Story
if err := json.Unmarshal([]byte(text), &story); err != nil && !closed {
panic("the stream was cut mid-object; repair before discarding")
}
for _, p := range check(story, seed, genre.Label, tone.Label) {
fmt.Println("CONTRACT:", p)
}
fmt.Println(story.Title, story.WordCount, len(strings.Fields(story.Story)))
static final String[] CRAFT = {"promise", "turn", "payoff", "unresolved"};
// The reply is one JSON object. Find it even when it arrives dressed: strip a
// leading ```json or ``` and a trailing ```, take the first '{', and scan to
// its matching '}' while tracking string state and backslash escapes.
static String firstObject(String raw) {
var t = raw.strip();
if (t.startsWith("```json")) t = t.substring(7);
else if (t.startsWith("```")) t = t.substring(3);
if (t.endsWith("```")) t = t.substring(0, t.length() - 3);
t = t.strip();
int start = t.indexOf('{');
if (start < 0) throw new IllegalStateException("no object in the reply");
int depth = 0;
boolean inStr = false, esc = false;
for (int i = start; i < t.length(); i++) {
char c = t.charAt(i);
if (esc) { esc = false; continue; }
if (c == '\\') { esc = true; continue; }
if (c == '"') { inStr = !inStr; continue; }
if (inStr) continue;
if (c == '{') depth++;
else if (c == '}' && --depth == 0) return t.substring(start, i + 1);
}
return t.substring(start); // truncated: repair, do not discard
}
// Run steps 3 and 4 (estimate, /me, run, poll), then parse and check:
// seed equals brief.seed, story non-empty, all four CRAFT keys present and
// non-empty, word_count an integer, and no craft note containing
// genre.label, genre.id, tone.label or tone.id - case-insensitively.
var text = firstObject(jobOutputOutput);
var story = parse(text); // your JSON library
for (var k : CRAFT) {
if (story.craft.get(k) == null || story.craft.get(k).isBlank())
System.out.println("CONTRACT: craft." + k + " missing");
}
System.out.println(story.title + " " + story.wordCount
+ " actual " + story.story.split("\\s+").length);
CRAFT = %w[promise turn payoff unresolved].freeze
# One JSON object, found even behind a fence or a sentence of preamble.
def first_object(raw)
t = raw.strip.sub(/\A```(?:json)?/, "").sub(/```\z/, "").strip
start = t.index("{") or raise "no object in the reply"
depth = 0
in_str = false
esc = false
(start...t.length).each do |i|
c = t[i]
if esc then esc = false
elsif c == "\\" then esc = true
elsif c == '"' then in_str = !in_str
elsif in_str then next
elsif c == "{" then depth += 1
elsif c == "}"
depth -= 1
return [t[start..i], true] if depth.zero?
end
end
[t[start..], false] # truncated
end
def check(v, sent)
bad = []
bad << "seed changed" unless v["seed"] == sent["brief"]["seed"]
bad << "empty story" if v["story"].to_s.strip.empty?
bad << "word_count not an int" unless v["word_count"].is_a?(Integer)
labels = [sent["genre"]["label"], sent["genre"]["id"],
sent["tone"]["label"], sent["tone"]["id"]].compact.map(&:downcase)
CRAFT.each do |k|
note = v.dig("craft", k).to_s
next bad << "craft.#{k} missing" if note.strip.empty?
bad << "craft.#{k} names the genre or the tone" if labels.any? { |l| note.downcase.include?(l) }
end
bad
end
# estimate -> /me -> run -> poll (steps 3 and 4), then:
text, = first_object(job["output"]["output"])
story = JSON.parse(text)
check(story, payload).each { |p| warn "CONTRACT: #{p}" }
puts "#{story["title"]} #{story["word_count"]} actual #{story["story"].split.size}"
<?php
const CRAFT = ["promise", "turn", "payoff", "unresolved"];
// One JSON object, found even behind a fence or a sentence of preamble.
function first_object(string $raw): array {
$t = trim(preg_replace('/^```(?:json)?|```$/', "", trim($raw)));
$start = strpos($t, "{");
if ($start === false) throw new RuntimeException("no object in the reply");
$depth = 0; $inStr = false; $esc = false;
for ($i = $start; $i < strlen($t); $i++) {
$c = $t[$i];
if ($esc) { $esc = false; continue; }
elseif ($c === "\\") { $esc = true; continue; }
elseif ($c === '"') { $inStr = !$inStr; continue; }
elseif ($inStr) { continue; }
elseif ($c === "{") { $depth++; }
elseif ($c === "}") { if (--$depth === 0) return [substr($t, $start, $i - $start + 1), true]; }
}
return [substr($t, $start), false]; // truncated
}
function check(array $v, array $sent): array {
$bad = [];
if (($v["seed"] ?? null) !== $sent["brief"]["seed"]) $bad[] = "seed changed";
if (trim($v["story"] ?? "") === "") $bad[] = "empty story";
if (!is_int($v["word_count"] ?? null)) $bad[] = "word_count not an int";
$labels = array_map("strtolower", array_filter([
$sent["genre"]["label"], $sent["genre"]["id"],
$sent["tone"]["label"], $sent["tone"]["id"],
]));
foreach (CRAFT as $k) {
$note = trim($v["craft"][$k] ?? "");
if ($note === "") { $bad[] = "craft.$k missing"; continue; }
foreach ($labels as $l) {
if ($l !== "" && str_contains(strtolower($note), $l)) {
$bad[] = "craft.$k names the genre or the tone";
break;
}
}
}
return $bad;
}
// estimate -> /me -> run -> poll (steps 3 and 4), then:
[$text, ] = first_object($job["output"]["output"]);
$story = json_decode($text, true);
foreach (check($story, $payload) as $p) { fwrite(STDERR, "CONTRACT: $p\n"); }
echo $story["title"], " ", $story["word_count"],
" actual ", count(preg_split('/\s+/', trim($story["story"]))), "\n";
static readonly string[] Craft = { "promise", "turn", "payoff", "unresolved" };
// One JSON object, found even behind a fence or a sentence of preamble.
static (string Text, bool Closed) FirstObject(string raw) {
var t = raw.Trim();
if (t.StartsWith("```json")) t = t[7..];
else if (t.StartsWith("```")) t = t[3..];
if (t.EndsWith("```")) t = t[..^3];
t = t.Trim();
var start = t.IndexOf('{');
if (start < 0) throw new Exception("no object in the reply");
int depth = 0; bool inStr = false, esc = false;
for (var i = start; i < t.Length; i++) {
var c = t[i];
if (esc) { esc = false; continue; }
if (c == '\\') { esc = true; continue; }
if (c == '"') { inStr = !inStr; continue; }
if (inStr) continue;
if (c == '{') depth++;
else if (c == '}' && --depth == 0) return (t[start..(i + 1)], true);
}
return (t[start..], false); // truncated: repair, do not discard
}
// estimate -> /me -> run -> poll (steps 3 and 4), then:
var (text, _) = FirstObject(jobOutputOutput);
var story = JsonDocument.Parse(text).RootElement;
if (story.GetProperty("seed").GetString() != sentSeed)
Console.Error.WriteLine("CONTRACT: seed changed");
if (story.GetProperty("word_count").ValueKind != JsonValueKind.Number)
Console.Error.WriteLine("CONTRACT: word_count not an integer");
var craft = story.GetProperty("craft");
foreach (var k in Craft) {
if (!craft.TryGetProperty(k, out var note) ||
string.IsNullOrWhiteSpace(note.GetString())) {
Console.Error.WriteLine($"CONTRACT: craft.{k} missing");
continue;
}
var low = note.GetString()!.ToLowerInvariant();
if (low.Contains(genreLabel.ToLowerInvariant()) ||
low.Contains(toneLabel.ToLowerInvariant()))
Console.Error.WriteLine($"CONTRACT: craft.{k} names the genre or the tone");
}
Console.WriteLine(story.GetProperty("title").GetString());
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.