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
| Code | HTTP | What it means | What to do |
|---|---|---|---|
unauthorized | 401 | The token is missing, expired or was revoked. | Mint a new one from the token panel. |
forbidden | 403 | The 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_required | 402 | The balance cannot cover the hold. | Call /estimate first and compare against /me. |
validation_error | 400 | The input shape is wrong — usually a missing script or an unknown task. | See the input contract below. |
rate_limited | 429 | Too many requests. | Back off; never tight-loop. |
job_failed | 200 | The 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.
task | What it produces | artifact.kind |
|---|---|---|
script | The read prepared: synthesis blocks, a pronunciation table, a normalisation table, and everything removed from the spoken text. | markdown |
cast | The voices cast: characteristics, an engine family, and four settings per voice, plus per-block direction. | json |
sfx | The sound cued: prompts, durations, levels, placements, ambience beds and mix notes. | csv |
music | The 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.
| Field | Type | Notes |
|---|---|---|
task | string | script | cast | sfx | music |
script | string | The narration script. The app clips at 48,000 characters, keeping both ends. |
medium | string | audiobook | explainer | podcast | advertisement | elearning | documentary | game | other |
voice_count | string | one | two | few | full. A hard cap on the cast lane, not a hint. |
pace | string | slow (130 wpm) | measured (150) | brisk (170) |
language | string | The language, or unstated to have it inferred. |
notes | string | Production context. Clipped at 4,000 characters. |
prescan | object | {flags: [], resources: []}. Optional over the API; when present the model must return one coverage_check entry per flag id. |
clip_note | string | Send it when you clipped the script yourself, so the model works around the gap instead of inventing it. |
carried_from | string | The 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"
import json, urllib.request
TOKEN = "YOUR_TOKEN"
BASE = "https://api.skillsafe.ai/v1/app-api"
def call(path, payload=None):
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(BASE + path, data=data, method="POST" if data else "GET")
req.add_header("Authorization", "Bearer " + TOKEN)
if data:
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
body = json.load(r)
if not body.get("ok"):
raise RuntimeError(body.get("error", {}).get("message", "request failed"))
return body["data"]
const TOKEN = "YOUR_TOKEN";
const BASE = "https://api.skillsafe.ai/v1/app-api";
async function call(path, payload) {
const res = await fetch(BASE + path, {
method: payload ? "POST" : "GET",
headers: {
"Authorization": "Bearer " + TOKEN,
...(payload ? { "Content-Type": "application/json" } : {})
},
body: payload ? JSON.stringify(payload) : undefined
});
const body = await res.json();
if (!body.ok) throw new Error(body.error?.message || "request failed");
return body.data;
}
package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
)
const (
token = "YOUR_TOKEN"
base = "https://api.skillsafe.ai/v1/app-api"
)
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct{ Code, Message string } `json:"error"`
}
func call(path string, payload any) (json.RawMessage, error) {
method := "GET"
var body io.Reader
if payload != nil {
b, _ := json.Marshal(payload)
body = bytes.NewReader(b)
method = "POST"
}
req, err := http.NewRequest(method, base+path, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, errors.New(env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class Narration {
static final String TOKEN = "YOUR_TOKEN";
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody == null) {
b.GET();
} else {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
}
HttpResponse<String> res = HttpClient.newHttpClient()
.send(b.build(), HttpResponse.BodyHandlers.ofString());
// Parse with the JSON library you already use, then check "ok".
return res.body();
}
}
require "json"
require "net/http"
require "uri"
TOKEN = "YOUR_TOKEN"
BASE = "https://api.skillsafe.ai/v1/app-api"
def call(path, payload = nil)
uri = URI(BASE + path)
req = payload ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if payload
req["Content-Type"] = "application/json"
req.body = JSON.generate(payload)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
body = JSON.parse(res.body)
raise body.dig("error", "message").to_s unless body["ok"]
body["data"]
end
<?php
const TOKEN = "YOUR_TOKEN";
const BASE = "https://api.skillsafe.ai/v1/app-api";
function call(string $path, ?array $payload = null) {
$ch = curl_init(BASE . $path);
$headers = ["Authorization: Bearer " . TOKEN];
if ($payload !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($body["ok"])) {
throw new RuntimeException($body["error"]["message"] ?? "request failed");
}
return $body["data"];
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
class Narration {
const string Token = "YOUR_TOKEN";
const string Base = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Http = new HttpClient();
static async Task<JsonElement> Call(string path, object payload = null) {
var req = new HttpRequestMessage(
payload == null ? HttpMethod.Get : HttpMethod.Post, Base + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (payload != null) {
req.Content = new StringContent(
JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
}
var res = await Http.SendAsync(req);
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (!doc.RootElement.GetProperty("ok").GetBoolean()) {
throw new Exception(doc.RootElement.GetProperty("error")
.GetProperty("message").GetString());
}
return doc.RootElement.GetProperty("data");
}
}
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}}
me = call("/me")
print(me["subject_type"], me.get("credits"))
const me = await call("/me");
console.log(me.subject_type, me.credits);
raw, err := call("/me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int64 `json:"credits"`
}
json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
String me = call("/me", null);
System.out.println(me);
me = call("/me")
puts "#{me["subject_type"]} #{me["credits"]}"
$me = call("/me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await Call("/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
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}}
payload = {
"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."
}
est = call("/estimate", payload)
print(est["model_alias"], "holds", est["hold_credits"], "credits")
me = call("/me")
if me.get("credits", 0) < est["hold_credits"]:
print("short by", est["hold_credits"] - me["credits"])
const payload = {
"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."
};
const est = await call("/estimate", payload);
console.log(est.model_alias, "holds", est.hold_credits);
const me = await call("/me");
if ((me.credits || 0) < est.hold_credits) {
console.log("short by", est.hold_credits - me.credits);
}
payload := map[string]any{
"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.",
}
raw, err := call("/estimate", payload)
if err != nil {
panic(err)
}
var est struct {
ModelAlias string `json:"model_alias"`
HoldCredits int64 `json:"hold_credits"`
}
json.Unmarshal(raw, &est)
fmt.Println(est.ModelAlias, est.HoldCredits)
String payload = """
{
"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."
}
""";
String est = call("/estimate", payload);
System.out.println(est);
payload = {
"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."
}
est = call("/estimate", payload)
puts "#{est["model_alias"]} holds #{est["hold_credits"]}"
$payload = [
"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.",
];
$est = call("/estimate", $payload);
echo $est["model_alias"], " holds ", $est["hold_credits"], PHP_EOL;
var payload = new {
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."
};
var est = await Call("/estimate", payload);
Console.WriteLine(est.GetProperty("hold_credits").GetInt64());
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"
import hashlib, time, json, urllib.request
def idem(payload, attempt=1):
blob = json.dumps([payload["task"], payload["script"], payload.get("medium"),
payload.get("voice_count"), payload.get("pace")], sort_keys=True)
h = hashlib.sha256(blob.encode()).hexdigest()[:12]
return "narration-desk:%s:%s:a%d" % (payload["task"], h, attempt)
def run(payload, attempt=1):
data = json.dumps(payload).encode()
req = urllib.request.Request(BASE + "/run", data=data, method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", idem(payload, attempt))
with urllib.request.urlopen(req) as r:
body = json.load(r)
if not body.get("ok"):
raise RuntimeError(body["error"]["message"])
return body["data"]["job_id"]
job_id = run(payload)
while True:
job = call("/jobs/" + job_id)
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error", "job failed"))
result = json.loads(job["output"]["output"])
print(result["lane"], result["posture"], len(result["findings"]), "findings")
function idem(payload, attempt = 1) {
const blob = JSON.stringify([payload.task, payload.script, payload.medium,
payload.voice_count, payload.pace]);
let h = 5381;
for (let i = 0; i < blob.length; i++) h = ((h << 5) + h + blob.charCodeAt(i)) >>> 0;
return `narration-desk:${payload.task}:${h.toString(36)}:a${attempt}`;
}
const res = await fetch(BASE + "/run", {
method: "POST",
headers: {
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": idem(payload)
},
body: JSON.stringify(payload)
});
const { data } = await res.json();
let job;
do {
await new Promise(r => setTimeout(r, 2000));
job = await call("/jobs/" + data.job_id);
} while (job.status !== "succeeded" && job.status !== "failed");
const result = JSON.parse(job.output.output);
console.log(result.lane, result.posture, result.findings.length);
// POST /run with the Idempotency-Key header, then poll GET /jobs/{id}.
b, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "narration-desk:script:c8f1a2:a1")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var env struct {
OK bool `json:"ok"`
Data struct{ JobID string `json:"job_id"` } `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
for {
raw, err := call("/jobs/"+env.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct{ Output string `json:"output"` } `json:"output"`
}
json.Unmarshal(raw, &job)
if job.Status == "succeeded" || job.Status == "failed" {
fmt.Println(job.Status, len(job.Output.Output), "bytes of reply")
break
}
time.Sleep(2 * time.Second)
}
String body = call("/run", payload); // add the Idempotency-Key header in call()
// Extract job_id with your JSON library, then:
// String job = call("/jobs/" + jobId, null);
// repeat until status is "succeeded" or "failed", sleeping 2s between polls.
// The reply text is at data.output.output and is one JSON object.
require "digest"
def idem(payload, attempt = 1)
blob = [payload["task"], payload["script"], payload["medium"],
payload["voice_count"], payload["pace"]].to_json
"narration-desk:#{payload["task"]}:#{Digest::SHA256.hexdigest(blob)[0, 12]}:a#{attempt}"
end
uri = URI(BASE + "/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idem(payload)
req.body = JSON.generate(payload)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body).dig("data", "job_id")
loop do
job = call("/jobs/#{job_id}")
break puts(JSON.parse(job["output"]["output"])["posture"]) if job["status"] == "succeeded"
raise "job failed" if job["status"] == "failed"
sleep 2
end
function idem(array $payload, int $attempt = 1): string {
$blob = json_encode([$payload["task"], $payload["script"], $payload["medium"],
$payload["voice_count"], $payload["pace"]]);
return "narration-desk:" . $payload["task"] . ":" .
substr(hash("sha256", $blob), 0, 12) . ":a" . $attempt;
}
$ch = curl_init(BASE . "/run");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: " . idem($payload),
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$jobId = json_decode(curl_exec($ch), true)["data"]["job_id"];
curl_close($ch);
do {
sleep(2);
$job = call("/jobs/" . $jobId);
} while (!in_array($job["status"], ["succeeded", "failed"], true));
$result = json_decode($job["output"]["output"], true);
echo $result["lane"], " ", $result["posture"], PHP_EOL;
static string Idem(dynamic payload, int attempt = 1) {
var blob = JsonSerializer.Serialize(payload);
var hash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(blob)));
return $"narration-desk:{payload.task}:{hash[..12].ToLower()}:a{attempt}";
}
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Headers.Add("Idempotency-Key", Idem(payload));
req.Content = new StringContent(
JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
var jobId = JsonDocument.Parse(await res.Content.ReadAsStringAsync())
.RootElement.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
string status;
do {
await Task.Delay(2000);
job = await Call("/jobs/" + jobId);
status = job.GetProperty("status").GetString();
} while (status != "succeeded" && status != "failed");
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}
import json, urllib.request
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps(payload).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", idem(payload))
raw = ""
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if not line.startswith("data:"):
continue
event = json.loads(line[5:].strip())
if event.get("type") == "delta":
raw += event.get("text", "")
elif event.get("type") == "done":
print("charged", event.get("charged_credits"))
result = json.loads(raw)
print(result["lane"], result["title"])
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": idem(payload)
},
body: JSON.stringify(payload)
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", raw = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const event = JSON.parse(line.slice(5).trim());
if (event.type === "delta") raw += event.text;
}
}
const result = JSON.parse(raw);
console.log(result.lane, result.title);
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "narration-desk:cast:c8f1a2:a1")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if !strings.HasPrefix(line, "data:") {
continue
}
var ev struct {
Type string `json:"type"`
Text string `json:"text"`
}
json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &ev)
if ev.Type == "delta" {
raw.WriteString(ev.Text)
}
}
fmt.Println(len(raw.String()), "bytes of reply")
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "narration-desk:cast:c8f1a2:a1")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
StringBuilder raw = new StringBuilder();
HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofLines())
.body()
.filter(l -> l.startsWith("data:"))
.forEach(l -> {
// parse l.substring(5) with your JSON library; append .text when .type is "delta"
raw.append(l);
});
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idem(payload)
req.body = JSON.generate(payload)
raw = ""
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
next unless line.start_with?("data:")
event = JSON.parse(line[5..].strip)
raw << event["text"].to_s if event["type"] == "delta"
end
end
end
end
result = JSON.parse(raw)
puts "#{result["lane"]} #{result["title"]}"
$raw = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: " . idem($payload),
]);
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$raw) {
foreach (explode("\n", $chunk) as $line) {
if (strpos($line, "data:") !== 0) { continue; }
$event = json_decode(trim(substr($line, 5)), true);
if (($event["type"] ?? "") === "delta") { $raw .= $event["text"]; }
}
return strlen($chunk);
});
curl_exec($ch);
curl_close($ch);
$result = json_decode($raw, true);
echo $result["lane"], " ", $result["title"], PHP_EOL;
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Headers.Add("Idempotency-Key", Idem(payload));
req.Content = new StringContent(
JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string line;
while ((line = await reader.ReadLineAsync()) != null) {
if (!line.StartsWith("data:")) continue;
var ev = JsonDocument.Parse(line.Substring(5).Trim()).RootElement;
if (ev.GetProperty("type").GetString() == "delta") {
raw.Append(ev.GetProperty("text").GetString());
}
}
var result = JsonDocument.Parse(raw.ToString()).RootElement;
Console.WriteLine(result.GetProperty("lane").GetString());
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.
| Field | Type | Vocabulary / default |
|---|---|---|
lane | string | script | cast | sfx | music. An unrecognised value is a hard parse failure. |
lane_inferred | boolean | true when the model had to choose the lane. |
title | string | Clipped to 160 characters. Defaults to "Untitled run". |
posture | string | ready | needs-work | blocked. Defaults to needs-work. |
verdict | string | One sentence naming the thing that decides the posture. |
medium | string | Echoes the input. Defaults to other. |
speakers | string[] | Defaults to ["NARRATOR"] when empty. |
est_seconds | number | Integer seconds, floored at 0. |
summary | string | Under 1200 characters. |
assumptions, open_questions | string[] | Blank entries are dropped. |
findings[].severity | string | critical | high | medium | low. Defaults to medium; the array is re-sorted most severe first. |
findings[].area | string | pronunciation | normalization | pacing | structure | casting | delivery | sound | music | rights | mix. Defaults to structure. |
coverage_check[].status | string | confirmed | set-aside | superseded. Defaults to set-aside. |
artifact.kind | string | none | markdown | json | csv. Forced to none when content is empty. |
next_lane.lane | string | A 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
- It does not synthesise audio, generate sound effects or render music. It returns prompts, settings and plans; you run them in your own tool.
- It does not name voices by library ID. Voices are described by characteristics you can search a library for, because a model cannot verify that an ID exists.
- It will not design around cloning a real, identifiable person's voice. That comes back as a
rightsfinding saying recorded consent is required. - It does not claim to have heard anything. If an output says a take "sounds good", that is a contract violation and worth reporting.