Drive Standby, Go from your own code
The one metered task, cues, is available over HTTP. It takes a talk's running order - each segment, how long it runs and what it leans on - together with the page's own figures (the clock against the slot, a cue light per segment, the board of needs) and returns a verdict (go, hold or rework) and a calling script: for every segment a standby check, a fallback that works without the thing that failed and a line to say while recovering, plus up to three cuts, the pre-show checks and what to watch out for. A natural use is a conference or class schedule that hands every speaker a cue sheet the week before, or a rehearsal checklist in a team's run-of-show doc.
The division of labour first: the page owns the arithmetic; the model owns the judgement and the words; the page re-checks the reply. Reading the segments, the clock, matching needs to the fixed list, the cue lights and the board come from cuekit.js, which runs in node as well as in the browser. The model never adds anything up: CueKit.buildInput hands it every figure. CueKit.check holds each reply to them: the verdict by the rule, one cue per segment in running order, a cut whenever the talk runs over, every reference a real segment, and no number, capitalised name, he/she, field name or brand that the input does not hold.
Get a token on the token page (step 2 below also shows how to mint a guest token in code).
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":"standby-go"}), 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, slot, segments, figures) 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: calling the show 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 verdict rule
Every run sends task: cues (the only task; a missing or unknown one is treated as cues). The verdict is decided by a fixed rule on the page's figures, checked in this order - the first that applies wins:
- rework - the running order is more than 10% longer than the slot (
over_limitisyes), or red segments carry more than half of it (red_over_halfisyes). - go - it fits the slot (
over_byis0:00) and no segment is red. - hold - everything else.
A segment's cue light comes from the needs it lists: internet, live software, a login and a video call count 3; video, sound, presenter notes and a cable count 2; everything else counts 1. Five or more is red, 2 to 4 amber, 0 or 1 green - a rough rule of thumb, not a measurement. The declared input schema carries scalars only, so the array- and object-valued fields (segments, board, figures) travel JSON-encoded as strings (CueKit.toWire does this). Every value is a string.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | cues. Required. |
slot | string | The slot in minutes, as a number (15, 7.5). Required. |
talk | string | What the talk is, up to 80 characters. May be empty. |
device, device_label | string | own_laptop, venue_pc, tablet, phone or remote, and its plain label. |
room, room_label | string | in_person, online or hybrid, and its plain label. |
segments | string (JSON) | [{id, what, length, start, end, needs, light}] - ids S1.., up to 25 segments; times are m:ss from the start; needs are need labels; light is green, amber or red. Required. |
board | string (JSON) | [{need, time, segments, lamp}] - each need, the time that leans on it, the segment ids and a lamp colour. |
figures | string (JSON) | {total, slot, over_by, to_spare, over_limit, red_count, red_time, red_over_half, amber_count, green_count, slot_ends_in}, every value a string. Required. |
notes | string | Free text from the speaker, up to 400 characters. |
clip_note, retry_note | string | What the page cut, when it cut something; and only on a reformat retry, what was wrong. |
The reply
The job's output.output is a string holding one JSON object: task, verdict, title, summary, cues (one {ref, standby, fallback, say} per segment, in running order), cuts (0-3 {ref, how}), preshow (2-6 strings) and watch_outs (0-3 strings). Parse with CueKit.parseReply, then normalize(reply), then check(reply, built) where built = CueKit.buildInput(form); md(reply, built, form) writes the Markdown and csv(built, reply) the cue sheet.
Worked examples
Replies produced against the app's prompt for the three sample talks (made up; long fields shortened; the full replies ship in example-result.js). Together they cover every verdict.
a reading-group welcome that fits its slot with nothing red - go
Input:
{
"task": "cues",
"talk": "Welcome night for the town library reading group",
"slot": "15",
"device": "venue_pc",
"device_label": "the venue's computer",
"room": "in_person",
"room_label": "in the room",
"segments": "[{\"id\":\"S1\",\"what\":\"Welcome and housekeeping\",\"length\":\"2:00\",\"start\":\"0:00\",\"end\":\"2:00\",\"needs\":[\"Microphone\"],\"light\":\"green\"},{\"id\":\"S2\",\"what\":\"Why this book, with three photos\",\"length\":\"4:00\",\"start\":\"2:00\",\"end\":\"6:00\",\"needs\":[\"Slides\",\"Clicker\"],\"light\":\"amber\"},{\"id\":\"S3\",\"what\":\"Read the opening page aloud\",\"length\":\"3:00\",\"start\":\"6:00\",\"end\":\"9:00\",\"needs\":[\"Props or handouts\"],\"light\":\"green\"},{\"id\":\"S ... (721 characters)",
"board": "[{\"need\":\"Microphone\",\"time\":\"6:00\",\"segments\":[\"S1\",\"S5\"],\"lamp\":\"green\"},{\"need\":\"Another person\",\"time\":\"6:00\",\"segments\":[\"S4\",\"S5\"],\"lamp\":\"green\"},{\"need\":\"Props or handouts\",\"time\":\"5:00\",\"segments\":[\"S3\",\"S4\"],\"lamp\":\"green\"},{\"need\":\"Slides\",\"time\":\"4:00\",\"segments\":[\"S2\"],\"lamp\":\"green\"},{\"need\":\"Clicker\",\"time\":\"4:00\",\"segments\":[\"S2\"],\"lamp\":\"green\"}]",
"figures": "{\"total\":\"15:00\",\"slot\":\"15:00\",\"over_by\":\"0:00\",\"to_spare\":\"0:00\",\"over_limit\":\"no\",\"red_count\":\"0\",\"red_time\":\"0:00\",\"red_over_half\":\"no\",\"amber_count\":\"3\",\"green_count\":\"2\",\"slot_ends_in\":\"\"}",
"notes": "First time speaking at the library. The room has a projector and one handheld mic."
}
Reply:
{
"task": "cues",
"verdict": "go",
"title": "Ready to go, with the slides as the one to watch",
"summary": "The talk's total of 15:00 lines up exactly with the 15:00 slot. The segment \"Why this book, with three photos\" leans most on the slides and the clicker, making it the one to watch.",
"cues": [
{
"ref": "S1",
"standby": "Check the microphone is on and working before you start.",
"fallback": "If the microphone fails, raise your voice so the whole room can hear you.",
"say": "I'll just project a little louder for now, you can still hear me fine."
},
{
"ref": "S2",
"standby": "Check the slides are loaded on the venue's computer and the clicker responds.",
"fallback": "If the slides fail, describe the three photos aloud from memory and move on.",
"say": "Let me just tell you about these three photos instead of showing them."
},
{
"ref": "S3",
"standby": "Check the props or handouts for this segment are ready in hand.",
"fallback": "If the props or handouts go missing, read the opening from memory instead.",
"say": "I don't have it in front of me, so let me tell you how it opens."
},
{
"ref": "S4",
"standby": "Check the props or handouts are ready and another person is set to help.",
"fallback": "If another person cannot help, pass the props or handouts round yourself while you keep talking.",
"say": "I'll pass these round myself so we can keep the discussion moving."
},
{
"ref": "S5",
"standby": "Check the microphone still works and another person is ready to help pass it.",
"fallback": "If the microphone fails, repeat each question aloud yourself before you answer it.",
"say": "I'll repeat each question out loud so everyone can follow along."
}
],
"cuts": [],
"preshow": [
"Check the microphone works before the welcome.",
"Confirm another person is ready to help with the cards and the microphone.",
"Have the opening page and discussion cards printed and within reach.",
"Load the slides onto the venue's computer and test the clicker works."
],
"watch_outs": [
"Only one microphone is available, so plan how it reaches the floor for questions.",
"This is your first time speaking at the library, so keep your fallback lines within reach."
]
}
a class science talk 0:30 over with one red clip - hold
Input:
{
"task": "cues",
"talk": "How volcanoes erupt - class science talk",
"slot": "8",
"device": "tablet",
"device_label": "a tablet",
"room": "in_person",
"room_label": "in the room",
"segments": "[{\"id\":\"S1\",\"what\":\"A question for the class\",\"length\":\"1:00\",\"start\":\"0:00\",\"end\":\"1:00\",\"needs\":[],\"light\":\"green\"},{\"id\":\"S2\",\"what\":\"How magma forms\",\"length\":\"2:00\",\"start\":\"1:00\",\"end\":\"3:00\",\"needs\":[\"Slides\",\"Presenter notes\"],\"light\":\"amber\"},{\"id\":\"S3\",\"what\":\"Eruption clip with sound\",\"length\":\"1:30\",\"start\":\"3:00\",\"end\":\"4:30\",\"needs\":[\"Internet\",\"Video\",\"Sound\"],\"light\":\"red\"},{\"id\":\"S4\",\"what\":\"Baking-s ... (777 characters)",
"board": "[{\"need\":\"Internet\",\"time\":\"1:30\",\"segments\":[\"S3\"],\"lamp\":\"amber\"},{\"need\":\"Presenter notes\",\"time\":\"2:00\",\"segments\":[\"S2\"],\"lamp\":\"amber\"},{\"need\":\"Video\",\"time\":\"1:30\",\"segments\":[\"S3\"],\"lamp\":\"amber\"},{\"need\":\"Sound\",\"time\":\"1:30\",\"segments\":[\"S3\"],\"lamp\":\"amber\"},{\"need\":\"Slides\",\"time\":\"3:00\",\"segments\":[\"S2\",\"S5\"],\"lamp\":\"green\"},{\"need\":\"Props or handouts\",\"time\":\"2:00\",\"segments\":[\"S4\"],\"lamp\":\"green\"},{\"ne ... (555 characters)",
"figures": "{\"total\":\"8:30\",\"slot\":\"8:00\",\"over_by\":\"0:30\",\"to_spare\":\"0:00\",\"over_limit\":\"no\",\"red_count\":\"1\",\"red_time\":\"1:30\",\"red_over_half\":\"no\",\"amber_count\":\"3\",\"green_count\":\"2\",\"slot_ends_in\":\"S6\"}",
"notes": "Presenting from my tablet to the classroom screen. Last time the presenter view did not show up and I lost my place."
}
Reply:
{
"task": "cues",
"verdict": "hold",
"title": "Running a little long, with one segment that can fail",
"summary": "The talk runs 8:30 against a slot of 8:00, which is 0:30 over. Eruption clip with sound leans on the internet, a video and sound, all fragile.",
"cues": [
{
"ref": "S1",
"standby": "Check you know your opening question and you're ready to start.",
"fallback": "Nothing here can fail; if time is short, ask the question quickly and move straight into the next segment.",
"say": "Let's move straight to the next part since our time is tight."
},
{
"ref": "S2",
"standby": "Check the slides are ready and presenter notes are showing on your tablet.",
"fallback": "If the presenter notes don't show, keep talking from the slides and what the room sees, without checking your private notes.",
"say": "Let me tell you what's next from memory while I get my place back."
},
{
"ref": "S3",
"standby": "Check the internet connection and that the video with sound is loaded and ready.",
"fallback": "If the clip or its sound does not play, skip it and describe what it shows from memory instead.",
"say": "Let me tell you what you would have seen and heard in the clip."
},
{
"ref": "S4",
"standby": "Check your props or handouts are ready and another person knows when to help.",
"fallback": "If another person is not ready, run the baking-soda volcano yourself using the props or handouts alone.",
"say": "Let me walk you through the baking-soda volcano step by step myself."
},
{
"ref": "S5",
"standby": "Check your slides are on the right slide and the clicker responds.",
"fallback": "If the clicker fails, advance the slides by hand or call out the three facts without slides.",
"say": "Let me just tell you the three facts directly while we sort the slides."
},
{
"ref": "S6",
"standby": "Check you know how much time is left for questions.",
"fallback": "Nothing here can fail; if time is short, take one question and close.",
"say": "Let's take one quick question now since we're almost out of time."
}
],
"cuts": [
{
"ref": "S6",
"how": "take one question only, then close instead of several."
},
{
"ref": "S3",
"how": "tell it as a story instead of playing the clip."
}
],
"preshow": [
"Confirm the internet connection works before you reach the eruption clip.",
"Check presenter notes show correctly on your tablet before you start.",
"Make sure the video is loaded and plays all the way through.",
"Test the sound at a level the room can hear.",
"Check the slides are loaded and in the right order.",
"Set out the props or handouts and confirm another person is ready."
],
"watch_outs": [
"The slot runs out during questions, so watch your closing time closely.",
"Last time presenter notes did not show on your tablet, so check early.",
"Eruption clip with sound leans on the internet, a video and sound together."
]
}
a meetup lightning talk 2:30 over, leaning on a live demo - rework
Input:
{
"task": "cues",
"talk": "Lightning talk: an app that waters my plants",
"slot": "5",
"device": "own_laptop",
"device_label": "your own laptop",
"room": "hybrid",
"room_label": "hybrid - in the room and online",
"segments": "[{\"id\":\"S1\",\"what\":\"Who I am\",\"length\":\"0:30\",\"start\":\"0:00\",\"end\":\"0:30\",\"needs\":[\"Slides\"],\"light\":\"green\"},{\"id\":\"S2\",\"what\":\"The problem: dry plants on holiday\",\"length\":\"1:00\",\"start\":\"0:30\",\"end\":\"1:30\",\"needs\":[\"Slides\",\"photo\"],\"light\":\"amber\"},{\"id\":\"S3\",\"what\":\"Live demo of the watering app\",\"length\":\"3:00\",\"start\":\"1:30\",\"end\":\"4:30\",\"needs\":[\"Live software\",\"Internet\",\"A login\",\"phone mirroring\"],\"light\": ... (701 characters)",
"board": "[{\"need\":\"Internet\",\"time\":\"5:00\",\"segments\":[\"S3\",\"S4\"],\"lamp\":\"red\"},{\"need\":\"A login\",\"time\":\"5:00\",\"segments\":[\"S3\",\"S4\"],\"lamp\":\"red\"},{\"need\":\"Live software\",\"time\":\"3:00\",\"segments\":[\"S3\"],\"lamp\":\"amber\"},{\"need\":\"phone mirroring\",\"time\":\"3:00\",\"segments\":[\"S3\"],\"lamp\":\"green\"},{\"need\":\"Slides\",\"time\":\"1:30\",\"segments\":[\"S1\",\"S2\"],\"lamp\":\"green\"},{\"need\":\"photo\",\"time\":\"1:00\",\"segments\":[\"S2\"],\"lamp\":\"green\"}, ... (489 characters)",
"figures": "{\"total\":\"7:30\",\"slot\":\"5:00\",\"over_by\":\"2:30\",\"to_spare\":\"0:00\",\"over_limit\":\"yes\",\"red_count\":\"2\",\"red_time\":\"5:00\",\"red_over_half\":\"yes\",\"amber_count\":\"1\",\"green_count\":\"2\",\"slot_ends_in\":\"S4\"}",
"notes": "The meetup streams every talk online. Five minutes is a hard stop - a bell rings."
}
Reply:
{
"task": "cues",
"verdict": "rework",
"title": "Too long, and leaning on the internet and a login",
"summary": "The talk runs 7:30 against a 5:00 slot, 2:30 over. Live demo of the watering app and Sensor readings on the website lean on the internet, a login, live software and phone mirroring.",
"cues": [
{
"ref": "S1",
"standby": "Check that the slides are ready on screen.",
"fallback": "If the slides fail, introduce yourself from memory and move on.",
"say": "Let me introduce myself while we sort out the slide."
},
{
"ref": "S2",
"standby": "Check the photo slide is loaded and ready to show.",
"fallback": "If the photo doesn't show, describe what it shows and continue talking.",
"say": "The problem is dry plants on holiday - let me describe it instead of showing the photo."
},
{
"ref": "S3",
"standby": "Check the login is live and the internet connection is working.",
"fallback": "If the login or internet fails, switch to a recorded video of the demo instead.",
"say": "Let me describe what the demo would show: the app watering the plants."
},
{
"ref": "S4",
"standby": "Check the login still works and the internet connection holds.",
"fallback": "If the internet or login fails, describe the sensor readings from memory instead.",
"say": "Let me tell you what the sensor readings would show instead."
},
{
"ref": "S5",
"standby": "Check the microphone is still on before taking the question.",
"fallback": "If the microphone fails, project your voice and repeat the question aloud.",
"say": "I'll repeat the question so everyone online and in the room can hear."
}
],
"cuts": [
{
"ref": "S4",
"how": "replace with a few printed screenshots of the sensor readings instead of loading live."
},
{
"ref": "S3",
"how": "replace with a short recorded video of the app watering the plants."
}
],
"preshow": [
"Confirm the internet connection is stable before you start.",
"Confirm the login for the demo is active and ready.",
"Record a backup video of the demo in case the internet drops.",
"Check that the live software opens correctly on your own laptop.",
"Confirm phone mirroring connects before the demo starts.",
"Confirm the online stream is live, since the meetup streams every talk."
],
"watch_outs": [
"Internet and a login both carry over half the talk; have a backup ready.",
"The slot runs out during Sensor readings on the website - have the cut ready.",
"The meetup streams every talk online, so the audience includes people watching remotely."
]
}
Build the input yourself
Load the script in node (global.window = global; require("./cuekit.js")), then:
const K = CueKit;
const form = K.form({ talk: "Team update", slot: "10", device: "venue_pc", room: "hybrid",
order: "Welcome | 1 | mic\nNumbers so far | 4 | slides, video call\nQuestions | 3 | mic" });
const r = K.read(form); // r.segs: the clock and cue lights; r.board: the needs; r.fig.verdict
const built = K.buildInput(form);
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 reply
// K.csv(built, reply) // the cue sheet, one row per segment
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://standby-go.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://standby-go.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"]
// standby-go.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://standby-go.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://standby-go.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
}
// StandbyGo.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 StandbyGo {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://standby-go.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://standby-go.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://standby-go.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://standby-go.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":"standby-go"}' | 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": "standby-go"})["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: "standby-go" })).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": "standby-go"}, 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", "standby-go"), 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: "standby-go" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "standby-go"])["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 = "standby-go" }))!["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": "cues", "talk": "How volcanoes erupt - class science talk", "slot": "8", "device": "tablet", "room": "in_person", "segments": "[{\"id\":\"S1\",\"what\":\"A question for the class\",\"length\":\"1:00\",\"start\":\"0:00\",\"end\":\"1:00\",\"needs\":[],\"light\":\"green\"},{\"id\":\"S2\",\"what\":\"How magma forms\",\"length\":\"2:00\",\"start\":\"1:00\",\"end\":\"3:00\",\"needs\":[\"Slides\",\"Presenter notes\"],\"light\":\"amber\"}, ...]", "board": "[...]", "figures": "{...}", "notes": "Presenting from my tablet to the classroom screen. Last time the presenter view did not show up and I lost my place."}
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": "cues", "talk": "How volcanoes erupt - class science talk", "slot": "8", "device": "tablet", "room": "in_person", "segments": "[{\"id\":\"S1\",\"what\":\"A question for the class\",\"length\":\"1:00\",\"start\":\"0:00\",\"end\":\"1:00\",\"needs\":[],\"light\":\"green\"},{\"id\":\"S2\",\"what\":\"How magma forms\",\"length\":\"2:00\",\"start\":\"1:00\",\"end\":\"3:00\",\"needs\":[\"Slides\",\"Presenter notes\"],\"light\":\"amber\"}, ...]", "board": "[...]", "figures": "{...}", "notes": "Presenting from my tablet to the classroom screen. Last time the presenter view did not show up and I lost my place."}
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: "cues", talk, slot, device, room, segments, board, figures, notes, ... (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 standby-go:<task>:<inputHash>:a<attempt> (the one task is cues), 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="standby-go:cues:$(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 = "standby-go:" + 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("verdict"))
import { createHash } from "node:crypto";
const key = `standby-go:${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.verdict);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "standby-go:" + 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 = "standby-go:" + 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 = "standby-go:#{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 = "standby-go:" . $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 = $"standby-go:{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: standby-go:cues:$(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": "cues", "talk": "How volcanoes erupt - class science talk", "slot": "8", "device": "tablet", "room": "in_person", "segments": "[{\"id\":\"S1\",\"what\":\"A question for the class\",\"length\":\"1:00\",\"start\":\"0:00\",\"end\":\"1:00\",\"needs\":[],\"light\":\"green\"},{\"id\":\"S2\",\"what\":\"How magma forms\",\"length\":\"2:00\",\"start\":\"1:00\",\"end\":\"3:00\",\"needs\":[\"Slides\",\"Presenter notes\"],\"light\":\"amber\"}, ...]", "board": "[...]", "figures": "{...}", "notes": "Presenting from my tablet to the classroom screen. Last time the presenter view did not show up and I lost my place."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "standby-go:cues:" + 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": `standby-go:cues:${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", "standby-go:cues:"+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", "standby-go:cues:" + 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" => "standby-go:cues:#{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: standby-go:cues:" . 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", $"standby-go:cues:{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);