← Narration Desk / API
Tokens

Driving Narration Desk from your own code

Everything this page's app does over HTTP, you can do too. The base URL is https://api.skillsafe.ai/v1/app-api. There is no per-app path segment and no per-request slug header — the app slug (narration-desk) is bound to the token once, when you mint it at /guest or sign in. Every call after that carries only Authorization: Bearer <token>.

Nothing here synthesises audio. The app returns a plan; you execute it in whichever voice tool you use. Replace YOUR_TOKEN with a real token from the token panel and read the code before you run it.

The envelope

Every response is {"ok": true, "data": {...}} or {"ok": false, "error": {"code": "...", "message": "...", "details": {...}}}. Check ok before reading data; an HTTP 200 with ok: false is possible on partially-served endpoints.

Error codes you will actually meet

CodeHTTPWhat it meansWhat to do
unauthorized401The token is missing, expired or was revoked.Mint a new one from the token panel.
forbidden403The token belongs to a different app.Tokens are bound to one app when minted — get a fresh one from the token panel rather than reusing a token minted for a different app.
payment_required402The balance cannot cover the hold.Call /estimate first and compare against /me.
validation_error400The input shape is wrong — usually a missing script or an unknown task.See the input contract below.
rate_limited429Too many requests.Back off; never tight-loop.
job_failed200The job reached a terminal failed state.Read data.error on the job.

Step 0 — the task field, first

Narration Desk is four lanes over one work object. Every run input carries a task field naming the lane, and the lane decides the shape of body in the reply. Send the wrong task and you get a correct answer to a different question.

taskWhat it producesartifact.kind
scriptThe read prepared: synthesis blocks, a pronunciation table, a normalisation table, and everything removed from the spoken text.markdown
castThe voices cast: characteristics, an engine family, and four settings per voice, plus per-block direction.json
sfxThe sound cued: prompts, durations, levels, placements, ambience beds and mix notes.csv
musicThe music briefed: one bed, sections, a ducking plan, one alternate and a list of what to avoid.markdown

If task is missing or unrecognised the model picks the closest lane, returns that lane's contract in full, and sets lane_inferred: true so you can tell. It never blends two lanes.

The input contract, in full

These are the exact fields the app sends. Only task and script are required.

FieldTypeNotes
taskstringscript | cast | sfx | music
scriptstringThe narration script. The app clips at 48,000 characters, keeping both ends.
mediumstringaudiobook | explainer | podcast | advertisement | elearning | documentary | game | other
voice_countstringone | two | few | full. A hard cap on the cast lane, not a hint.
pacestringslow (130 wpm) | measured (150) | brisk (170)
languagestringThe language, or unstated to have it inferred.
notesstringProduction context. Clipped at 4,000 characters.
prescanobject{flags: [], resources: []}. Optional over the API; when present the model must return one coverage_check entry per flag id.
clip_notestringSend it when you clipped the script yourself, so the model works around the gap instead of inventing it.
carried_fromstringThe lane whose output you carried into script. Tells the model the text is already prepared.

Step 1 — a tiny client

One helper that adds the auth header and unwraps the envelope. Everything below uses it.

# There is no helper in cURL. Export the two constants once and reuse them.
export SS_TOKEN="YOUR_TOKEN"
export SS_BASE="https://api.skillsafe.ai/v1/app-api"

# Every call then looks like this:
curl -s "$SS_BASE/me" \
  -H "Authorization: Bearer $SS_TOKEN"

Step 2 — get a token

Open the token panel. A guest token is minted automatically and is enough for /me and /estimate; running a lane is metered, so it needs a personal token, which comes from signing in. The panel has a "Copy shell export" button that produces exactly the line the cURL examples want.

A token you paste anywhere is a token you should treat as compromised. Removing it from a browser is not a server-side revocation.

Step 3 — GET /me

Who the token belongs to and what the balance is. Free. subject_type is user for a personal token and guest otherwise.

curl -s "$SS_BASE/me" \
  -H "Authorization: Bearer $SS_TOKEN"

# {"ok":true,"data":{"subject_type":"user","username":"you","credits":184203}}

Step 4 — POST /estimate

Free, and it starts no job. It returns model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled. The hold differs per lane, because the prompts and output caps differ — re-estimate whenever you change task, and never show one lane's hold for another lane's run.

hold_credits is a reservation, not a price. Compare it against /me's credits before you run, so you never submit into a 402.

curl -s "$SS_BASE/estimate" \
  -H "Authorization: Bearer $SS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "script", "script": "NARRATOR: On 1969-07-20, at 20:17 UTC, a machine weighing 15,103 kg came to rest in a place with no name.", "medium": "audiobook", "voice_count": "two", "pace": "measured", "language": "English", "notes": "Chapter 3 of a popular-history audiobook. Two readers already contracted."}'

# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#                    "markup_bps":1000,"hold_credits":4120,"min_credits":260,
#                    "sponsor_enabled":false}}

Step 5 — POST /run, then poll

/run returns a job_id immediately. Poll GET /jobs/{job_id} until status is terminal (succeeded or failed). The reply text is at data.output.output.

Always send an idempotency key, and include the lane in it. Two lanes over the same script are two distinct runs and must not collide; a network blip on one of them must not double-bill. The app uses narration-desk:<task>:<hash of the input>:a<attempt>.

# The header name is Idempotency-Key, and the lane belongs in the value.
curl -s "$SS_BASE/run" \
  -H "Authorization: Bearer $SS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: narration-desk:script:c8f1a2:a1" \
  -d @input.json

# {"ok":true,"data":{"job_id":"job_abc123"}}

# then poll
curl -s "$SS_BASE/jobs/job_abc123" \
  -H "Authorization: Bearer $SS_TOKEN"

Step 6 — POST /run-stream (SSE)

Same input and the same Idempotency-Key rules, but the reply arrives as text/event-stream deltas. This is what the page uses, because the staged progress card advances on section keys appearing in the stream: "findings", then the lane's own body keys, then "coverage_check". Concatenate every delta and parse the whole thing once at the end.

If the stream dies mid-flight, keep what you have. The envelope is emitted in order, so counting which of its keys arrived tells you honestly how much of the answer you got.

curl -N -s "$SS_BASE/run-stream" \
  -H "Authorization: Bearer $SS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: narration-desk:cast:c8f1a2:a1" \
  -d @input.json

# data: {"type":"delta","text":"{\"lane\": \"cast\","}
# data: {"type":"delta","text":"\"posture\": \"ready\","}
# data: {"type":"done","charged_credits":1180}

Step 7 — the output contract

The reply is one JSON object. The envelope is identical across all four lanes; only body differs. These field lists are taken from the parsing code in app.js, not from intent — anything the app renders is guaranteed to exist after normalisation, and anything outside these vocabularies is coerced to the stated default.

FieldTypeVocabulary / default
lanestringscript | cast | sfx | music. An unrecognised value is a hard parse failure.
lane_inferredbooleantrue when the model had to choose the lane.
titlestringClipped to 160 characters. Defaults to "Untitled run".
posturestringready | needs-work | blocked. Defaults to needs-work.
verdictstringOne sentence naming the thing that decides the posture.
mediumstringEchoes the input. Defaults to other.
speakersstring[]Defaults to ["NARRATOR"] when empty.
est_secondsnumberInteger seconds, floored at 0.
summarystringUnder 1200 characters.
assumptions, open_questionsstring[]Blank entries are dropped.
findings[].severitystringcritical | high | medium | low. Defaults to medium; the array is re-sorted most severe first.
findings[].areastringpronunciation | normalization | pacing | structure | casting | delivery | sound | music | rights | mix. Defaults to structure.
coverage_check[].statusstringconfirmed | set-aside | superseded. Defaults to set-aside.
artifact.kindstringnone | markdown | json | csv. Forced to none when content is empty.
next_lane.lanestringA lane id, or "" when nothing sensible follows.

The four body shapes

One worked reply per lane, trimmed to one entry per array. Every array is present even when empty.

task: "script"

{
  "lane": "script",
  "body": {
    "blocks": [
      {
        "id": "B-01",
        "speaker": "NARRATOR",
        "text": "On the twentieth of July, nineteen sixty-nine, at twenty seventeen U T C, a machine weighing fifteen thousand one hundred and three kilograms came to rest in a place with no name.",
        "chars": 181,
        "pacing": "unhurried, a held beat before \"no name\"",
        "est_seconds": 14,
        "notes": "the bracketed direction on line 4 moved here"
      }
    ],
    "pronunciations": [
      {
        "as_written": "NASA",
        "say_as": "NASS-uh",
        "kind": "acronym",
        "reason": "read as a word, not spelled out; spelling it out would sound like a different agency"
      }
    ],
    "normalizations": [
      {
        "as_written": "1969-07-20",
        "rewritten": "the twentieth of July, nineteen sixty-nine",
        "why": "an ISO date is read as three separate numbers by every normaliser"
      }
    ],
    "removed": [
      {
        "text": "[unhurried, close to the microphone]",
        "line": 4,
        "moved_to": "delivery note on B-01"
      }
    ],
    "read_notes": [
      "Keep the heteronym \"read\" as past tense throughout; the pronunciation table records it."
    ]
  }
}

task: "cast"

{
  "lane": "cast",
  "body": {
    "voices": [
      {
        "speaker": "NARRATOR",
        "role": "carries the chapter and the framing",
        "characteristics": {
          "gender_presentation": "any",
          "age_range": "40-55",
          "accent": "general American",
          "timbre": "warm, slightly dry",
          "energy": "measured"
        },
        "settings": {
          "stability": 0.55,
          "similarity": 0.8,
          "style": 0.25,
          "speed": 0.98,
          "speaker_boost": true
        },
        "settings_reason": "long-form narration wants stability high enough to survive an hour without drift, and style low so the prose is not performed at the listener",
        "search_terms": [
          "warm",
          "documentary",
          "narrator",
          "mature"
        ]
      }
    ],
    "engine": {
      "family": "high-expressiveness",
      "why": "two speakers with genuinely different registers",
      "tradeoff": "higher latency and more variance between takes than a low-latency model",
      "not_chosen": [
        {
          "family": "low-latency",
          "why_not": "nothing here is real time, so the quality cost buys nothing"
        }
      ]
    },
    "direction": [
      {
        "speaker": "NARRATOR",
        "at": "B-01",
        "note": "land \"no name\" and stop; do not lift into the next line"
      }
    ],
    "collapse": []
  }
}

task: "sfx"

{
  "lane": "sfx",
  "body": {
    "cues": [
      {
        "id": "S-01",
        "at": "",
        "line": 12,
        "trigger": "a gap of 1.5 seconds, twice",
        "label": "telemetry dropout",
        "prompt": "abrupt loss of a faint carrier tone into dead air, thin control-room speaker, no reverb tail, twice",
        "duration_seconds": 1.5,
        "loop": false,
        "level_db": -20,
        "placement": "replacing a pause",
        "why": "the gap is the subject of the paragraph and the words alone cannot carry its length"
      }
    ],
    "ambience": [
      {
        "label": "control room",
        "prompt": "low continuous room tone with distant relay clicks and fan hum, small hard-walled room, no music",
        "duration_seconds": 20.0,
        "loop": true,
        "level_db": -30,
        "covers": "B-02 to B-04"
      }
    ],
    "mix_notes": [
      "Nothing sits above -10 dB; every cue is under the voice, not beside it."
    ],
    "restraint": {
      "cue_count": 1,
      "why": "one moment in this chapter implies a sound the prose does not already carry"
    }
  }
}

task: "music"

{
  "lane": "music",
  "body": {
    "bed": {
      "prompt": "patient ambient post-rock floor, felt piano and low sustained strings, no percussion, unresolved, slow swells, wide but uncrowded",
      "genre": "ambient post-rock",
      "mood": "patient, unresolved",
      "bpm": 66,
      "key_feel": "minor, unresolved",
      "instrumentation": [
        "felt piano",
        "low strings"
      ],
      "duration_seconds": 78,
      "why": "the chapter withholds its judgement to the last line, so the bed must not resolve either"
    },
    "sections": [
      {
        "label": "cold open",
        "covers": "B-01",
        "intensity": "low",
        "prompt_delta": "piano alone, no strings yet",
        "duration_seconds": 12,
        "why": "the first figure lands better against near-silence"
      }
    ],
    "ducking": [
      {
        "under": "B-02",
        "target_db": -24,
        "why": "the densest numbers in the chapter are here and the bed must get out of the way"
      }
    ],
    "alternates": [
      {
        "prompt": "intermittent double bass and brushed snare pulse, entering only between speaker turns and never under a word",
        "why_different": "treats music as punctuation rather than as a floor, which suits a chapter built on two voices disagreeing"
      }
    ],
    "avoid": [
      "No cadence at the end: the chapter continues and a resolved ending contradicts the text."
    ]
  }
}

Reconciling the prescan over the API

The web page runs a deterministic script reader before it runs the model, and sends its findings as prescan.flags. Over the API that field is optional, but if you send it the model owes you exactly one coverage_check entry per flag id — no more, no fewer. That is the cheapest available check on whether the answer actually engaged with your text:

sent  = {f["id"] for f in payload["prescan"]["flags"]}
got   = {c["flag_id"] for c in result["coverage_check"]}
assert got == sent, ("dropped: %s, invented: %s" % (sent - got, got - sent))

Each flag is {id, severity, line, occurrences, label, samples, owning_lane, in_lane, why}. occurrences is the count over the whole script, not the count on line, and a flag whose in_lane is false should come back set-aside with a note naming the lane that owns it rather than confirmed against an invented finding.

Chaining the lanes

The lanes are stages of one job, and the second is meant to run on the first's output. Take the script lane's body.blocks, join their text, send that as the next lane's script, and set carried_from: "script" so the model knows the text is already prepared and does not re-flag normalisations that have been applied.

prepared = "\n\n".join(
    (b["speaker"] + ": " if b["speaker"] != "NARRATOR" else "") + b["text"]
    for b in script_result["body"]["blocks"]
)
cast_input = dict(payload, task="cast", script=prepared, carried_from="script")

Remember to re-estimate: the hold is per lane. And give the new run its own idempotency key — the lane is part of the key precisely so this chain does not collide with itself.

What this API will not do