Drive Read On Air from your own code
The one metered task, edit, is available over HTTP. It takes a short personal belief essay (the kind read aloud on the radio in a few minutes), the time budget in minutes and the page's own counts of the draft (length against the budget at 150 words a minute, the sentences that state a belief, the sentences that preach, time markers and big abstract words). It returns a verdict (name_it, find_the_moment, off_the_pulpit or on_air), the belief in one sentence, the moment the draft tells, three to five notes that quote the draft word for word, a suggested first and last line, and a working title.
The division of labour first: the page owns the counts, the timing, the timed script and the verdict rule's two counted steps; the model owns judging whether the draft tells one particular moment and all the wording; the page then re-checks the reply against the draft.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"read-on-air"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field (task, minutes, draft, page_facts) is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: a producer's reading is metered and needs a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field and the verdicts
Every run sends task: edit (the one task is edit); a missing or different one is still answered as edit. The verdict follows a fixed check order, stopping at the first that fits: name_it when the page counts no sentence that states a belief in the first person; find_the_moment when the draft tells no one particular moment from the writer's life (the model's judgement); off_the_pulpit when the page counts 3 or more sentences that tell the listener what to do or think; otherwise on_air.
Input (every field is a string)
| Field | Required | What it holds |
|---|---|---|
task | yes | Always edit. |
minutes | yes | The time budget: 2, 3 or 4 (300, 450 or 600 words at 150 words a minute). |
draft | yes | The essay, paragraphs separated by blank lines; over 9,000 characters it keeps its start and end with the middle marked as cut. |
page_facts | yes | One key | ... per line: length, sentences, belief_lines (count and each sentence quoted), pulpit_lines (count and each sentence quoted), time_markers, big_words, verdict_rule. Build it with CredoKit.buildInput (below) so the counts match the page's. |
clip_note | no | What the page shortened, if anything. |
retry_note | no | Only on the page's one reformat retry, after an unusable reply. |
Reply (one JSON object)
{"task": "edit", "title": "...", "belief": "I believe ...", "verdict": "on_air", "verdict_reason": "...",
"moment": "...", "notes": [{"kind": "keep", "quote": "...", "text": "..."}, {"kind": "cut", "quote": "...", "text": "..."}],
"first_line": "...", "last_line": "..."}
| Field | What it holds |
|---|---|
verdict, verdict_reason | The verdict id by the check order above; one sentence of at most 30 words on what decided it. |
belief | One sentence starting "I believe", at most 30 words - the clearest belief sentence, or for name_it the belief the draft circles, in its own words. |
moment | The moment the draft tells, 3 to 12 words copied from it; empty for find_the_moment. |
notes | Three to five {kind, quote, text}: kind is keep, cut, show or ask; quote is 3 to 25 words copied word for word from the draft; text at most 30 words. |
first_line, last_line | Suggested opening and closing lines (at most 30 words each) built only from the draft's own details; first_line is empty for find_the_moment. |
title | A working title of at most 8 words from the draft's own words. |
The page then checks the reply (CredoKit.check): the verdict against its own counts, that the moment and every quote appear in the draft word for word, that a cut is named when the draft is over time, that the suggested lines use no names the draft does not, every number in the prose, leaked field ids and the word limits.
Worked examples
These are the four free samples on the page: the exact input and a stand-in model's reply, unedited. Together they cover all four verdicts - a tomato row that never says what it believes (name the belief), kindness told in big words (find the moment), a bus-stop good morning that turns into a lecture and runs over two minutes (step off the pulpit), and a porch light left on (ready for air).
A tomato row that never says what it believes - name the belief
Input:
{
"task": "edit",
"minutes": "2",
"draft": "Every August my grandmother handed me a paper bag and pointed at the far row of tomatoes. She never came with me. She said the far row was mine because I was the only one patient enough to find the ripe ones under the leaves.\n\nThe summer I was nine, the bag tore. Eleven tomatoes rolled into the dirt and split. I cried so hard that the neighbour's dog started barking. My grandmother walked out, looked at the mess, and sat down in the dirt next to me. She picked up the worst one, wiped it on her apron and bit into it like an apple.\n\n\"Still a tomato,\" she said.\n\nWe ate four of them right there, sitting in the row, juice running down to our elbows. The rest went into a sauce that night. Nobody mentioned the bag.\n\nI am forty-one now and I still think about that row whenever something I care about splits open in front of me. A ruined thing is usually still the thing. You can sit down next to it.",
"page_facts": "length | 177 words; about 1:11 read aloud at 150 words a minute (an estimate of a calm pace); the budget for 2 minutes is 300 words; 123 words under\nsentences | 15 sentences in 5 paragraphs; the longest has 23 words\nbelief_lines | 0 sentences state a belief in the first person\npulpit_lines | 0 sentences tell the listener what to do or think\ntime_markers | 3 found: \"august\", \"the summer i was nine\", \"that night\" (a hint only - markers are not a moment)\nbig_words | none of the page's list of big abstract words\nverdict_rule | check in order and stop at the first that fits: name_it when belief_lines counts 0 sentences; find_the_moment when the draft tells no one particular moment; off_the_pulpit when pulpit_lines counts 3 or more sentences; otherwise on_air"
}
Reply:
{
"task": "edit",
"title": "Still a Tomato",
"belief": "I believe a ruined thing is usually still the thing, and you can sit down next to it.",
"verdict": "name_it",
"verdict_reason": "No sentence anywhere states the belief as I believe or similar, so the belief needs to be named before anything else.",
"moment": "Eleven tomatoes rolled into the dirt and split",
"notes": [
{
"kind": "keep",
"quote": "She picked up the worst one, wiped it on her apron and bit into it like an apple.",
"text": "This is the moment that earns the whole essay - plain, physical, and it shows exactly what the belief means without saying it."
},
{
"kind": "ask",
"quote": "A ruined thing is usually still the thing.",
"text": "Is this the belief, stated plainly - or is there a shorter line still coiled inside it you haven't said yet?"
},
{
"kind": "show",
"quote": "I still think about that row whenever something I care about splits open in front of me.",
"text": "This tells the feeling; the torn bag and the dirt already show it - trust that scene to carry the meaning here too."
},
{
"kind": "keep",
"quote": "You can sit down next to it.",
"text": "This closing line lands because it comes straight out of the scene, not above it - keep it exactly as plain as this."
}
],
"first_line": "The summer I was nine, the bag tore and tomatoes rolled into the dirt.",
"last_line": "A ruined thing is usually still the thing - you can sit down in the dirt next to it."
}
Kindness told in big words - find the moment
Input:
{
"task": "edit",
"minutes": "3",
"draft": "I believe in kindness. Kindness is the most important value a person can have, and it is something the world needs more than ever.\n\nKindness costs nothing, but it means everything. It is the glue that holds communities together and the light that guides us through dark times. When people are kind, everything is better. When they are not, everything suffers.\n\nI have always tried to be a kind person. It is not always easy, because life can be hard and people can be difficult. But I think that kindness is a choice we make every day, and it is a choice that matters. Kindness is love in action.\n\nSome people think kindness is weakness. I disagree. Kindness takes courage and strength. It takes honesty and patience. It takes respect for the dignity of every human being.\n\nWe all need more of it. That is why I believe in kindness, and why I will keep believing in it for the rest of my life.",
"page_facts": "length | 164 words; about 1:06 read aloud at 150 words a minute (an estimate of a calm pace); the budget for 3 minutes is 450 words; 286 words under\nsentences | 17 sentences in 5 paragraphs; the longest has 21 words\nbelief_lines | 2 sentences state a belief in the first person: [1] \"I believe in kindness.\" [2] \"That is why I believe in kindness, and why I will keep believing in it for the rest of my life.\"\npulpit_lines | 0 sentences tell the listener what to do or think\ntime_markers | none found (a hint only - a moment can be told without one)\nbig_words | kindness x8, courage x1, dignity x1, honesty x1, love x1, patience x1, respect x1, strength x1\nverdict_rule | check in order and stop at the first that fits: name_it when belief_lines counts 0 sentences; find_the_moment when the draft tells no one particular moment; off_the_pulpit when pulpit_lines counts 3 or more sentences; otherwise on_air"
}
Reply:
{
"task": "edit",
"title": "Kindness Is a Choice We Make",
"belief": "I believe in kindness.",
"verdict": "find_the_moment",
"verdict_reason": "The draft offers a belief and general statements about kindness but never tells one specific scene from a particular time and place in the writer's life.",
"moment": "",
"notes": [
{
"kind": "ask",
"quote": "I have always tried to be a kind person.",
"text": "Picture one particular day you tried to be kind - where were you, and what happened in that moment?"
},
{
"kind": "ask",
"quote": "It is the glue that holds communities together and the light that guides us through dark times.",
"text": "Was there one time you saw kindness hold a group together - who was there, and where did it happen?"
},
{
"kind": "keep",
"quote": "Kindness is love in action.",
"text": "This line is plain, warm, and easy to say aloud - good to keep as is."
}
],
"first_line": "",
"last_line": "That is why I believe in kindness, and why I will keep believing in it for the rest of my life."
}
A bus-stop good morning that lectures, over two minutes - step off the pulpit
Input:
{
"task": "edit",
"minutes": "2",
"draft": "I believe in saying good morning to strangers.\n\nThe winter I was nineteen I worked the early shift at a bakery, and I caught the 5:40 bus from the corner by the laundromat. There were always the same six people at that stop, standing apart, hoods up, looking at their phones or at nothing. For three weeks none of us said a word.\n\nThen one morning an older man in a green scarf turned to me and said, \"Good morning.\" That was all. I was so surprised I said it back too loudly. The woman next to us laughed, and then she said it too.\n\nAfter that, the stop changed. We still mostly stood apart, but we said good morning. When the man in the green scarf stopped coming in February, three of us noticed on the same day and asked each other if anyone knew him. Nobody did. We had only ever known his good morning.\n\nYou should try it tomorrow. We must stop staring at our phones and start looking at each other again. Everyone needs to be seen by someone at least once a day, and it is our job to see them. Don't wait for someone else to go first. Society is falling apart because we have forgotten the simple things, and the simplest thing is two words. If we all did this, the world would be a better place, and loneliness would not be the problem it is today. Think about how many people you pass every day without a word. A smile is free. A greeting is free. Our grandparents knew this, and somewhere along the way we lost it. It really is that easy, and there is no excuse not to do it. Put the phone in your pocket, lift your head, and say it. It will change your day, and it might change theirs.\n\nI still say good morning at every bus stop, and most mornings somebody says it back.",
"page_facts": "length | 328 words; about 2:11 read aloud at 150 words a minute (an estimate of a calm pace); the budget for 2 minutes is 300 words; 28 words over\nsentences | 27 sentences in 6 paragraphs; the longest has 27 words\nbelief_lines | 1 sentence states a belief in the first person: [1] \"I believe in saying good morning to strangers.\"\npulpit_lines | 4 sentences tell the listener what to do or think: [1] \"You should try it tomorrow.\" [2] \"We must stop staring at our phones and start looking at each other again.\" [3] \"Everyone needs to be seen by someone at least once a day, and it is our job to see them.\" [4] \"Don't wait for someone else to go first.\"\ntime_markers | 4 found: \"the winter i was nineteen\", \"5:40\", \"one morning\", \"february\" (a hint only - markers are not a moment)\nbig_words | none of the page's list of big abstract words\nverdict_rule | check in order and stop at the first that fits: name_it when belief_lines counts 0 sentences; find_the_moment when the draft tells no one particular moment; off_the_pulpit when pulpit_lines counts 3 or more sentences; otherwise on_air"
}
Reply:
{
"task": "edit",
"title": "Good Morning at the Bus Stop",
"belief": "I believe in saying good morning to strangers.",
"verdict": "off_the_pulpit",
"verdict_reason": "Four lines near the end tell the listener what to do, enough to pull it off the air despite a clear belief and moment.",
"moment": "an older man in a green scarf turned to me",
"notes": [
{
"kind": "keep",
"quote": "We had only ever known his good morning.",
"text": "This last line of the bus-stop story lands quietly and lets the small moment carry the belief without any explaining."
},
{
"kind": "cut",
"quote": "We must stop staring at our phones and start looking at each other again.",
"text": "This command preaches what the opening sentence already said; cutting it removes about fourteen words and shortens the piece toward time."
},
{
"kind": "cut",
"quote": "Everyone needs to be seen by someone at least once a day, and it is our job to see them.",
"text": "This explains the belief instead of trusting the bus stop to show it; cutting removes twenty words toward the time budget."
},
{
"kind": "show",
"quote": "Society is falling apart because we have forgotten the simple things",
"text": "This claim about society stays told; the bus stop scene already shows it, so trust that image instead of this line."
}
],
"first_line": "The winter I was nineteen, I caught the 5:40 bus from the corner by the laundromat, where six people always stood apart, hoods up.",
"last_line": "I still believe in saying good morning to strangers, at every bus stop, the way the man in the green scarf once said it to me."
}
A porch light left on - ready for air
Input:
{
"task": "edit",
"minutes": "3",
"draft": "I believe in leaving the porch light on.\n\nThe night I turned seventeen I missed the last train home. I had argued with my mother that afternoon about something I can no longer remember, and I had left the house saying I might not come back at all. By the time I walked the four miles from the station it was after two, and I was rehearsing what I would say when I found the door locked and the house dark.\n\nFrom the end of our street I could see it: the yellow bulb over the front step, the one with the dead moths in it, burning. The door was unlocked. My mother was asleep on the couch with her shoes still on. She had not waited up to shout at me. She had just left the light on and fallen asleep near the door.\n\nI did not wake her. I turned the light off, put a blanket over her, and went to bed. In the morning neither of us said anything about the argument. She made toast. I ate it.\n\nI have lived in five places since then, and I have left the porch light on in every one of them, even when nobody was out. My partner jokes that the electric bill has a line for it. My son, who is twelve, asked me once who I was waiting for. I told him I was not waiting for anybody. I was just making sure that if somebody came, they would know the door was the kind that opens.\n\nA light like that does not fix the argument. It does not say who was right. It only says that the way back is still there, and that somebody thought about it before they fell asleep.\n\nThat is what I believe in: a small light, left on, for whoever is still walking home.",
"page_facts": "length | 313 words; about 2:05 read aloud at 150 words a minute (an estimate of a calm pace); the budget for 3 minutes is 450 words; 137 words under\nsentences | 23 sentences in 7 paragraphs; the longest has 33 words\nbelief_lines | 2 sentences state a belief in the first person: [1] \"I believe in leaving the porch light on.\" [2] \"That is what I believe in: a small light, left on, for whoever is still walking home.\"\npulpit_lines | 0 sentences tell the listener what to do or think\ntime_markers | 2 found: \"the night i turned seventeen\", \"that afternoon\" (a hint only - markers are not a moment)\nbig_words | none of the page's list of big abstract words\nverdict_rule | check in order and stop at the first that fits: name_it when belief_lines counts 0 sentences; find_the_moment when the draft tells no one particular moment; off_the_pulpit when pulpit_lines counts 3 or more sentences; otherwise on_air"
}
Reply:
{
"task": "edit",
"title": "The Porch Light, Left On",
"belief": "I believe in leaving the porch light on.",
"verdict": "on_air",
"verdict_reason": "The draft names a clear belief, tells one particular night with time and place, and never tells the listener what to do - it's ready for air.",
"moment": "The night I turned seventeen I missed the last train home",
"notes": [
{
"kind": "keep",
"quote": "the yellow bulb over the front step, the one with the dead moths in it, burning",
"text": "This small detail makes the porch light real and particular, not just an idea - keep it exactly as is."
},
{
"kind": "cut",
"quote": "even when nobody was out",
"text": "This trailing clause repeats what's already clear - cut it so the sentence lands cleaner."
},
{
"kind": "show",
"quote": "It does not say who was right",
"text": "This states the lesson directly; the toast and quiet morning already show it without saying so."
},
{
"kind": "ask",
"quote": "I told him I was not waiting for anybody",
"text": "What would it cost to tell him the truer, longer answer, the one this essay just gave?"
}
],
"first_line": "The night I turned seventeen, I missed the last train home and walked the four miles from the station after two.",
"last_line": "I believe in a small light, left on, for whoever is still walking home, the kind of door that opens."
}
Build the input yourself
Load the script in node (global.window = global; require("./credokit.js")), then:
const K = CredoKit;
const built = K.buildInput({ minutes: "3", draft: "I believe in leaving the porch light on.\n\nThe night I turned seventeen..." });
built.a; // { total, secs, budget, over, belief: [...], pulpit: [...], times, abstract, ... } - the free counts
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes // the page's re-check of the reading
// K.whereIs("From the end of our street I could see it", built.form.draft) // where a quote falls: { word: 81, time: "0:32" } in the porch sample
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://read-on-air.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://read-on-air.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// read-on-air.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://read-on-air.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://read-on-air.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { 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 map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// ReadOnAir.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class ReadOnAir {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://read-on-air.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://read-on-air.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://read-on-air.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://read-on-air.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"read-on-air"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "read-on-air"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "read-on-air" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "read-on-air"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "read-on-air"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "read-on-air" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "read-on-air"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "read-on-air" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "edit", "minutes": "3", "draft": "I believe in ...", "page_facts": "..."} - see the field table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "edit", "minutes": "3", "draft": "I believe in ...", "page_facts": "..."} - see the field table
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "edit", minutes, draft, page_facts} (see the field table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses read-on-air:<task>:<inputHash>:a<attempt> (the one task is edit), and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="read-on-air:edit:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "read-on-air:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("title"))
import { createHash } from "node:crypto";
const key = `read-on-air:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.title);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "read-on-air:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "read-on-air:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply);
require "digest"
key = "read-on-air:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["title"]].inspect
$key = "read-on-air:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["title"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"read-on-air:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: read-on-air:edit:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "edit", "minutes": "3", "draft": "I believe in ...", "page_facts": "..."} - see the field table
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "read-on-air:edit:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `read-on-air:edit:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "read-on-air:edit:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "read-on-air:edit:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "read-on-air:edit:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: read-on-air:edit:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"read-on-air:edit:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);