Driving it from your own code
Everything the page does is an ordinary HTTP call against
https://api.skillsafe.ai/v1/app-api. Pick a language once with the tabs and every
sample on the page follows it.
The envelope
Every response is {"data": ...} on success and {"error": {...}} on
failure. Read data, not the top level. The most common mistake against this API is
reading one level too shallow: the model's text lives at
data.output.output, not at data.output.
| Code | Means | What to do |
|---|---|---|
| 400 | The body did not validate | Check the field names against the table below. Note that /estimate validates almost nothing, so a body it accepts can still be wrong. |
| 401 | No token, or a stale one | Mint a guest token. A cold 401 on a first visit is the correct answer, not a fault. |
| 402 | Not enough credits | Compare hold_credits against /me before submitting. |
| 403 | A guest tried to run | Runs need a signed-in token. Free calls do not. |
| 404 | Unknown job, record or collection | Check the id. A guest sees only rows its own subject wrote. |
| 429 | Rate limited | Back off. Similarity search is 30/min per IP, tighter than the other endpoints. |
1. A tiny client, and a token
The slug goes in the body of /guest. An X-App-Slug
header returns 400 slug is required on that endpoint, whatever other docs say.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
-H "Content-Type: application/json" \
-d '{"slug":"movie-musical-generator"}'
# -> {"data":{"token":"aut_...","guest_id":"gst_..."}}
# The slug goes in the BODY. An X-App-Slug header returns 400 slug is required.
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "movie-musical-generator"
def call(path, body=None, token=None, method=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data,
method=method or ("POST" if data else "GET"))
req.add_header("Content-Type", "application/json")
# api.skillsafe.ai returns a Cloudflare 1010 to the default urllib agent.
req.add_header("User-Agent", "movie-musical-generator-docs/1.0")
if token:
req.add_header("Authorization", "Bearer " + token)
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]
token = call("/guest", {"slug": SLUG})["token"]
print(token[:12] + "...")
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "movie-musical-generator";
async function call(path, { body, token, method } = {}) {
const res = await fetch(BASE + path, {
method: method || (body ? "POST" : "GET"),
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: "Bearer " + token } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json();
if (!res.ok) throw new Error(json.error?.message || res.status);
return json.data;
}
const { token } = await call("/guest", { body: { slug: SLUG } });
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const slug = "movie-musical-generator"
func call(path string, body any, token string) (map[string]any, error) {
var buf *bytes.Buffer = bytes.NewBuffer(nil)
method := "GET"
if body != nil {
b, _ := json.Marshal(body)
buf = bytes.NewBuffer(b)
method = "POST"
}
req, _ := http.NewRequest(method, base+path, buf)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var out struct{ Data map[string]any }
json.NewDecoder(res.Body).Decode(&out)
return out.Data, nil
}
func main() {
d, _ := call("/guest", map[string]string{"slug": slug}, "")
fmt.Println(d["token"])
}
import java.net.URI;
import java.net.http.*;
class Client {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "movie-musical-generator";
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String body, String token) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json");
if (token != null) b.header("Authorization", "Bearer " + token);
b = (body == null) ? b.GET() : b.POST(HttpRequest.BodyPublishers.ofString(body));
return HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString()).body();
}
public static void main(String[] a) throws Exception {
System.out.println(call("/guest", "{\"slug\":\"" + SLUG + "\"}", null));
}
}
require "json"
require "net/http"
BASE = URI("https://api.skillsafe.ai/v1/app-api")
SLUG = "movie-musical-generator"
def call(path, body = nil, token = nil)
uri = URI(BASE.to_s + path)
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}" if token
req.body = JSON.dump(body) if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body)["data"]
end
token = call("/guest", { "slug" => SLUG })["token"]
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "movie-musical-generator";
function call(string $path, ?array $body = null, ?string $token = null) {
$headers = ["Content-Type: application/json"];
if ($token) { $headers[] = "Authorization: Bearer " . $token; }
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POST => $body !== null,
CURLOPT_POSTFIELDS => $body !== null ? json_encode($body) : null,
]);
$out = json_decode(curl_exec($ch), true);
curl_close($ch);
return $out["data"] ?? null;
}
$token = call("/guest", ["slug" => SLUG])["token"];
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "movie-musical-generator";
var http = new HttpClient();
async Task<JsonElement> Call(string path, object? body = null, string? token = null) {
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, Base + path);
if (body is not null) req.Content = JsonContent.Create(body);
if (token is not null) req.Headers.Add("Authorization", "Bearer " + token);
var res = await http.SendAsync(req);
var doc = await res.Content.ReadFromJsonAsync<JsonElement>();
return doc.GetProperty("data");
}
var token = (await Call("/guest", new { slug = Slug })).GetProperty("token").GetString();
2. Who am I
/me returns exactly three fields: subject_type,
subject_id and credits. There is no username and no email, so the
signed-in test is subject_type === "user" and nothing else.
curl -s https://api.skillsafe.ai/v1/app-api/me -H "Authorization: Bearer YOUR_TOKEN"
# -> {"data":{"subject_type":"guest","subject_id":"gst_...","credits":0}}
# Exactly three fields. There is no username, no email and no name.
# The signed-in test is subject_type == "user".
me = call("/me", token=token)
print(me["subject_type"], me["credits"])
signed_in = me["subject_type"] == "user"
const me = await call("/me", { token });
const signedIn = me.subject_type === "user";
me, _ := call("/me", nil, token)
fmt.Println(me["subject_type"], me["credits"])
String me = call("/me", null, token);
System.out.println(me);
me = call("/me", nil, token)
signed_in = me["subject_type"] == "user"
$me = call("/me", null, $token);
$signedIn = ($me["subject_type"] ?? "") === "user";
var me = await Call("/me", null, token);
var signedIn = me.GetProperty("subject_type").GetString() == "user";
3. What a run will cost
/estimate is free, creates no job and returns the model binding along with the
hold. hold_credits is what gets reserved: it prices the full output cap, and
the settled charge is usually far lower.
A warning worth more than the sample. This endpoint performs no body
validation. A bare string, a number and null all come back ok with a
well-formed estimate and the correct model. So a clean estimate proves the model binding and says
nothing whatever about whether your input shape is right. Check the field table yourself.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
-H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
-d '{"task": "treatment","premise": "A municipal parking officer discovers that every car she tickets vanishes at midnight.","stance": "wry","stance_wants": "Play it with a straight face and let the absurdity do the work.","stance_overrides": "no-narrator-of-record","idiom": "brass-and-marches","idiom_conventions": "Municipal band instrumentation: brass, snare, low woodwind. Marches and processionals.","idiom_overrides": "","audience": "all-ages","avoid": "","acts": 2,"numbers": 8}'
# -> {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
# "hold_credits":...,"min_credits":...}}
body = {
"task": "treatment",
"premise": "A municipal parking officer discovers that every car she tickets vanishes at midnight.",
"stance": "wry",
"stance_wants": "Play it with a straight face and let the absurdity do the work.",
"stance_overrides": "no-narrator-of-record",
"idiom": "brass-and-marches",
"idiom_conventions": "Municipal band instrumentation: brass, snare, low woodwind. Marches and processionals.",
"idiom_overrides": "",
"audience": "all-ages",
"avoid": "",
"acts": 2,
"numbers": 8
}
est = call("/estimate", body, token=token)
print(est["model_alias"], est["hold_credits"])
const body = {
"task": "treatment",
"premise": "A municipal parking officer discovers that every car she tickets vanishes at midnight.",
"stance": "wry",
"stance_wants": "Play it with a straight face and let the absurdity do the work.",
"stance_overrides": "no-narrator-of-record",
"idiom": "brass-and-marches",
"idiom_conventions": "Municipal band instrumentation: brass, snare, low woodwind. Marches and processionals.",
"idiom_overrides": "",
"audience": "all-ages",
"avoid": "",
"acts": 2,
"numbers": 8
};
const est = await call("/estimate", { body, token });
console.log(est.model_alias, est.hold_credits);
body := map[string]any{
"task": "treatment", "premise": "...", "stance": "wry",
"stance_wants": "...", "stance_overrides": "no-narrator-of-record",
"idiom": "brass-and-marches", "idiom_conventions": "...", "idiom_overrides": "",
"audience": "all-ages", "avoid": "", "acts": 2, "numbers": 8,
}
est, _ := call("/estimate", body, token)
fmt.Println(est["model_alias"], est["hold_credits"])
String body = "{\"task\":\"treatment\",\"premise\":\"...\",\"acts\":2,\"numbers\":8}";
System.out.println(call("/estimate", body, token));
body = { "task" => "treatment", "premise" => "...", "stance" => "wry",
"acts" => 2, "numbers" => 8 }
est = call("/estimate", body, token)
puts est["hold_credits"]
$body = ["task" => "treatment", "premise" => "...", "acts" => 2, "numbers" => 8];
$est = call("/estimate", $body, $token);
echo $est["hold_credits"];
var est = await Call("/estimate",
new { task = "treatment", premise = "...", acts = 2, numbers = 8 }, token);
Console.WriteLine(est.GetProperty("hold_credits"));
4. Run it, then poll
Pass an Idempotency-Key header on every run. A retry after a network blip must
reuse the same key or you pay twice; two different lanes over the same show must use different
keys or the second returns the first one's result.
# 1. submit
curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
-H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: mmg-treatment-a1b2c3d4-1" \
-d '{"task": "treatment","premise": "A municipal parking officer discovers that every car she tickets vanishes at midnight.","stance": "wry","stance_wants": "Play it with a straight face and let the absurdity do the work.","stance_overrides": "no-narrator-of-record","idiom": "brass-and-marches","idiom_conventions": "Municipal band instrumentation: brass, snare, low woodwind. Marches and processionals.","idiom_overrides": "","audience": "all-ages","avoid": "","acts": 2,"numbers": 8}'
# -> {"data":{"job_id":"job_..."}}
# 2. poll until terminal
curl -s https://api.skillsafe.ai/v1/app-api/jobs/job_... -H "Authorization: Bearer YOUR_TOKEN"
# -> {"data":{"status":"succeeded","output":{"output":"TITLE: ...\nTAGLINE: ..."},
# "charged_credits":...}}
# The text is at data.output.output - one level deeper than it looks.
import time
job = call("/run", body, token=token) # {"job_id": "job_..."}
while True:
j = call("/jobs/" + job["job_id"], token=token)
if j["status"] in ("succeeded", "failed"):
break
time.sleep(1.5)
text = (j.get("output") or {}).get("output", "") # one level deeper
print(text[:400])
const { job_id } = await call("/run", { body, token });
let job;
for (;;) {
job = await call("/jobs/" + job_id, { token });
if (job.status === "succeeded" || job.status === "failed") break;
await new Promise((r) => setTimeout(r, 1500));
}
const text = job.output?.output ?? "";
job, _ := call("/run", body, token)
id := job["job_id"].(string)
var j map[string]any
for {
j, _ = call("/jobs/"+id, nil, token)
s, _ := j["status"].(string)
if s == "succeeded" || s == "failed" { break }
time.Sleep(1500 * time.Millisecond)
}
out := j["output"].(map[string]any)["output"].(string)
String job = call("/run", body, token);
// parse job_id, then poll /jobs/{id} until status is succeeded or failed,
// and read data.output.output for the text.
job = call("/run", body, token)
loop do
j = call("/jobs/#{job['job_id']}", nil, token)
break (@text = j.dig("output", "output")) if %%w[succeeded failed].include?(j["status"])
sleep 1.5
end
$job = call("/run", $body, $token);
do {
usleep(1500000);
$j = call("/jobs/" . $job["job_id"], null, $token);
} while (!in_array($j["status"], ["succeeded", "failed"], true));
$text = $j["output"]["output"] ?? "";
var job = await Call("/run", body, token);
var id = job.GetProperty("job_id").GetString();
JsonElement j;
do {
await Task.Delay(1500);
j = await Call("/jobs/" + id, null, token);
} while (j.GetProperty("status").GetString() is not ("succeeded" or "failed"));
var text = j.GetProperty("output").GetProperty("output").GetString();
5. Streaming, and what actually arrives
/run-stream is server-sent events. The frame format is a named event line, a data
line, then a blank line - not a {"type":"delta"} object, which does not exist on this
API and which a surprising number of published samples describe.
In a browser you will not see deltas at all. Measured on this deployment: a
page receives tick heartbeats and one final done, while curl receives
the full delta stream. That is why this app polls a job rather than building a live preview - a
streaming preview on a web page would be dead code.
curl -N -s -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
-H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{"task":"treatment","premise":"...","acts":2,"numbers":8}'
# The wire format is a named event, then its data, then a BLANK LINE:
#
# event: job
# data: {"job_id":"job_..."}
#
# event: delta
# data: {"text":"TITLE: "}
#
# event: done
# data: {"status":"succeeded","output":{"output":"..."}}
#
# Event names are: job, delta, done, pending, error.
# There is no {"type":"delta"} frame - a parser written for that never fires.
# Read the frames yourself: split on a blank line, then take the
# "event:" and "data:" lines out of each frame.
import json, urllib.request
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps(body).encode(), method="POST")
req.add_header("Authorization", "Bearer " + token)
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "text/event-stream")
req.add_header("User-Agent", "movie-musical-generator-docs/1.0")
buf = ""
with urllib.request.urlopen(req) as r:
for chunk in r:
buf += chunk.decode("utf-8", "replace")
while "\n\n" in buf:
frame, buf = buf.split("\n\n", 1)
name = data = None
for line in frame.split("\n"):
if line.startswith("event:"): name = line[6:].strip()
elif line.startswith("data:"): data = line[5:].strip()
if name == "delta" and data:
print(json.loads(data).get("text", ""), end="")
// The vendored SDK does this for you:
// await ss.runStream(input, { onDelta: (t) => out.append(t), onJob: (j) => ... });
//
// Note onDelta receives a plain STRING, not an object - the SDK calls
// opts.onDelta(data.text || ""). Writing `d => buf += d.text` collects nothing.
//
// And note that IN A BROWSER the server sends heartbeat ticks rather than
// deltas, so onDelta never fires on a page at all. Use ss.run + waitForJob
// there, which is what this app does.
// bufio.Scanner over the response body, splitting on a blank line;
// per frame take the "event:" and "data:" lines.
// Event names: job, delta, done, pending, error.
// HttpResponse.BodyHandlers.ofLines(), buffer until a blank line,
// then read the "event:" and "data:" lines out of the frame.
# Net::HTTP with a block, accumulate until "\n\n", then parse the frame's
# event: and data: lines. Event names: job, delta, done, pending, error.
// curl with CURLOPT_WRITEFUNCTION, buffer until "\n\n", then split the
// frame into its event: and data: lines.
// StreamReader over the response, buffer until a blank line, then read
// the event: and data: lines out of each frame.
6. Saved shows
The app declares one collection, shows, readable only by its owner. Records nest
their fields under doc.
# The app declares one collection, `shows`, acl_read owner / acl_write user.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/shows/query \
-H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
-d '{"sort":{"field":"ran_at","dir":"desc"},"limit":10}'
# -> {"data":{"records":[{"record_id":"rec_...","doc":{...}}],"next_cursor":null}}
# Records nest under `doc`. Reading fields flat off the record returns nothing.
rows = call("/collections/shows/query",
{"sort": {"field": "ran_at", "dir": "desc"}, "limit": 10},
token=token)["records"]
for r in rows:
d = r["doc"] # never read fields flat off r
print(d["title"], d["song_count"])
// with the vendored SDK
const { records } = await ss.collection("shows").query({
where: { lane: { eq: "treatment" } }, // every where entry is an operator object
sort: { field: "ran_at", dir: "desc" }, // the key is `sort`; order_by is ignored
limit: 10,
});
records.forEach((r) => console.log(r.doc.title));
// similar() resolves to the ARRAY itself, not { records }
const hits = await ss.collection("shows").similar("the one about the parking officer");
rows, _ := call("/collections/shows/query", map[string]any{
"sort": map[string]string{"field": "ran_at", "dir": "desc"}, "limit": 10,
}, token)
String rows = call("/collections/shows/query",
"{\"sort\":{\"field\":\"ran_at\",\"dir\":\"desc\"},\"limit\":10}", token);
rows = call("/collections/shows/query",
{ "sort" => { "field" => "ran_at", "dir" => "desc" }, "limit" => 10 },
token)["records"]
rows.each { |r| puts r["doc"]["title"] }
$rows = call("/collections/shows/query",
["sort" => ["field" => "ran_at", "dir" => "desc"], "limit" => 10], $token)["records"];
foreach ($rows as $r) { echo $r["doc"]["title"], PHP_EOL; }
var rows = await Call("/collections/shows/query",
new { sort = new { field = "ran_at", dir = "desc" }, limit = 10 }, token);
The input contract
Taken from the code that builds the request, not from intent. Route on
task first - it selects which contract the model writes to, and the
remaining fields differ by lane. All fields are scalars; there is no nesting anywhere in the
input.
Every request
| Field | Type | What it is |
|---|---|---|
task | string | treatment or number. Missing or unrecognised falls back to treatment and the reply says so. |
stance | string | An id: earnest, wry, operatic, procedural, melancholy, raucous. |
stance_wants | string | One sentence saying what the stance is for. This is the authority on the stance, not the id. |
stance_overrides | string | Comma-separated house-rule names the stance may break. Empty means none. |
idiom | string | An id for the score's idiom: golden-age, brass-and-marches, folk-americana, jazz-standard, synth, gospel-soul, chamber-indie, music-hall. |
idiom_conventions | string | Instrumentation and song form, in a sentence or two. Described by convention, never by artist. |
idiom_overrides | string | As above, for the idiom. |
audience | string | all-ages or grown-up. |
avoid | string | Free text naming things the writer should not use. A constraint on the writing. |
task: treatment
| Field | Type | What it is |
|---|---|---|
premise | string | The premise. Clipped from the middle at 2400 characters, with the cut announced in-band. |
acts | number | 2 or 3. |
numbers | number | How many songs the list must hold: 6, 8, 10 or 12. |
task: number
| Field | Type | What it is |
|---|---|---|
show_context | string | A plain-text digest of the treatment: title, logline, world, cast, acts, song list. Treated as established fact. |
number_brief | string | The one song-list row to write, as labelled lines: SLOT, ACT, TITLE, SINGER, FORM, FUNCTION, CHANGE. |
length | string | short (three to five sections) or full (five to eight). |
A number request in full
{
"task": "number",
"show_context": "TITLE: ...\nLOGLINE: ...\nACT I: ...\nSONG 5 (act II): ...",
"number_brief": "SLOT: 5\nACT: II\nTITLE: ...\nSINGER: ...\nFORM: ...\nFUNCTION: ...\nCHANGE: ...",
"length": "full",
"stance": "wry",
"stance_wants": "...",
"stance_overrides": "no-narrator-of-record",
"idiom": "brass-and-marches",
"idiom_conventions": "...",
"idiom_overrides": "",
"audience": "all-ages",
"avoid": ""
}
The output contract
Plain labelled lines, not JSON. A label in capitals, a colon, then the value. Multi-part values are separated by a vertical bar and the part order is fixed. Labelled lines are used because each one completes on its own: a reply cut off at sixty per cent still yields sixty per cent of a show, where a truncated JSON object yields nothing.
task: treatment
TITLE: <the show's title>
TAGLINE: <one clause>
LOGLINE: <one or two sentences>
WORLD: <two or three sentences>
SCORE: <what the score sounds like>
CAST: <name> | <who they are> | <what they want> | <what they will not admit>
ACT: <roman numeral> | <act title> | <what changes across it>
BEAT: <act> | <beat title> | <what happens>
SONG: <slot> | <act> | <title> | <singer> | <form> | <function> | <what changes>
NOTE: <a short paragraph>
CAST repeats three to five times, ACT exactly acts
times, BEAT three to five times per act, and SONG exactly
numbers times with slots running continuously from 1 across act breaks.
task: number
NUMBER: <the song title, unchanged from the brief>
PLACEMENT: <act> | <where in the act> | <who is on screen>
FORM: <the musical form>
JOB: <what this number has to accomplish>
SECTION: <a section label>
LYRIC: <one line of the lyric>
CRAFT: rhyme | <the scheme, and where it departs>
CRAFT: meter | <the syllable shape>
CRAFT: turn | <where the number changes>
CRAFT: staging | <what the camera and the room are doing>
NOTE: <a short paragraph>
SECTION and LYRIC interleave: each section label is followed by its
own lyric lines, one line per LYRIC. All four CRAFT keys are emitted, in
that order.
The refusal
REFUSED: <one sentence naming what could not be done and what to ask instead>
Emitted alone, with no other labels, and only when nothing is left of the request after the
content rules have been applied. The far more common outcome is a full result plus a
NOTE: clause saying what was changed - an original riff instead of an adaptation, a
fictional character instead of a named living person, a genre instead of a named writer's voice.
Parsing it
Split on newlines. A line matching ^([A-Z][A-Z0-9_]{1,14})\s*:\s*(.*)$ is a
labelled line; anything else is a continuation of the line above it. Split multi-part values on
| and trim. If the reply was truncated, drop only a trailing line that is
demonstrably unfinished - running that repair on a complete reply deletes its last line, which in
a lyric is the whole point of the song.
What it will not write
The app writes original material only. It will not reproduce the words of an existing song in
any form, will not hand back a real film or musical's plot, characters or song titles under a new
name, will not attribute anything to a real writer, composer or performer, and will not build a
show on a living person's private life. Those rules bind every field of every reply, and a request
that cannot be served without breaking one comes back as the version that can be written, with the
change named on the NOTE: line.