Drive Birthday Issue from your own code
The one metered step - writing one birthday issue from the facts about one person - is available over HTTP. A natural use is a family group chat bot or a party-planning tool: your code collects the facts, sends them with the page's plan, and gets back every page of an eight-page mini magazine with the fact behind each line.
The split first: the page reads and filters the facts; the model writes; the page holds the model to the facts. Reading the facts, holding back sore subjects and keep-out topics, planning the pages and the print / pad / hold reading all happen in issuekit.js in the browser (or in node - see "Build the input yourself" below). Held-back facts are never sent.
Get a token on the token page (step 1 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":"birthday-issue"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare: never wrap it as {"input": {…}}.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or task 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: writing an issue 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 comes first
Every run sends task. There is one task, issue. It returns verdict, masthead, issue_line, summary (for the maker, not printed); cover {headline, lines: [{text, move, facts}]} with 3 or 4 cover lines, each naming one of ten headline moves; pages [{page, title, lines: [{text, facts, blank}]}] for pages 2 to 8; asks (facts worth adding, 0-5) and caveats (0-2).
| Verdict | Meaning |
|---|---|
print | Ready to print: 8 or more usable facts and every page built on one; no blanks. |
pad | Print with blanks: 4 to 7 usable facts; pages without one carry fill-in lines (blank: true, a run of underscores) for the party guests. |
hold | Hold the presses: fewer than 4 usable facts. pages and cover.lines are empty and asks says what to add. |
Pages, in reading order: 1 cover, 2 editor's letter, 3 by the numbers, 4-5 feature, 6 quiz, 7 classifieds, 8 the year ahead. The ten cover-line move values: consequence, picture, number-as-hero, two-beat turn, naming, reader's-own-story, open question, voice, sound, sized claim.
Input fields
The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (IssueKit.toWire does this). The schema marks every field except notes and retry_note as required, so an estimate on a body missing one returns a warning naming it.
| Field | Type | What |
|---|---|---|
task | string | Always issue. |
today | string | YYYY-MM-DD. |
name | string | The birthday person. |
age | string | The age they are turning, or (not given). |
birthday | string | YYYY-MM-DD or (not given). |
from | string | Who is making the issue, or (not given). |
tone | string | sweet, playful or grand. |
masthead | string | The magazine's name, or (choose one). |
facts | string (JSON) | [{id, kind, text}] - the usable facts only; kind is quote, number, moment, habit, love, joke, plan or fact. |
withheld | string | How many facts the page held back (a count; their text is never sent). |
off_limits | string | Keep-out topics, comma-separated, or (none). |
page_plan | string (JSON) | [{page, kind, facts}] - the fact ids the page suggests for each page. |
page_reads | string (JSON) | [{level, code, text}] - the page's flags. |
page_verdict | string | The page's own reading: print, pad or hold. |
notes | string | Optional, the maker's wishes; past 600 characters it is cut. |
retry_note | string | Only on a reformat retry. |
The reply
The job's output.output is a string holding one JSON object. Parse it with IssueKit.parseReply, then normalize, then check it with check(reply, state): every cited fact id must exist and not be held back, every printed line except the letter's greeting and sign-off must cite a fact, every quoted phrase must appear in a cited fact, every figure on page 3 must appear in its facts (or be the age), and no keep-out topic may appear.
Worked examples
Replies produced against the app's prompt for the sample issues (made-up people and facts; shortened here; the full replies ship in example-result.js). One per verdict.
task: issue - Grandpa Walt turns 80 - print
Input:
{
"task": "issue",
"today": "2026-10-19",
"name": "Walt",
"age": "80",
"birthday": "2026-11-14",
"from": "The grandkids",
"tone": "grand",
"masthead": "(choose one)",
"facts": "[{\"id\":\"F1\",\"kind\":\"quote\",\"text\":\"\\\"Measure twice, cut once, then measure again.\\\"\"},{\"id\":\"F2\",\"kind\":\"number\",\"text\":\"Has done the Sunday crossword in pen for 52 years\"},\"... 8 more\"]",
"withheld": "0",
"off_limits": "hearing aid",
"page_plan": "[{\"page\":1,\"kind\":\"cover\",\"facts\":[\"F2\",\"F9\",\"F3\"]},{\"page\":2,\"kind\":\"letter\",\"facts\":[\"F1\"]},\"... 6 more\"]",
"page_reads": "[]",
"page_verdict": "print",
"notes": ""
}
Reply:
{
"task": "issue",
"verdict": "print",
"masthead": "The Walt Gazette",
"issue_line": "Eighty: The Commemorative Issue",
"summary": "Ten usable facts give every page from 2 to 8 something real to stand on, so this prints clean with no blanks. The crossword streak, the birdhouses, the lake drive, the pancake flip, and the bike lesson carry the issue, with the train-trip plan and butterscotch habit rounding out the back page. One more line Walt always says would make a future cover even stronger, but nothing here is missing.",
"cover": {
"headline": "Walt Turns 80: Eighty Years of Measuring Twice",
"lines": [
{
"text": "Fourteen birdhouses, built for the neighbours since spring.",
"move": "number-as-hero",
"facts": [
"F9"
]
},
{
"text": "Biscuit, Clover, and Pete: the squirrels Walt feeds each morning.",
"move": "naming",
"facts": [
"F4"
]
},
"..."
]
},
"pages": [
{
"page": 2,
"title": "Editor's Letter",
"lines": [
{
"text": "Hear ye, hear ye: Walt turns eighty.",
"facts": [],
"blank": false
},
{
"text": "As he says: \"Measure twice, cut once, then measure again.\"",
"facts": [
"F1"
],
"blank": false
},
{
"text": "With great admiration, The grandkids.",
"facts": [],
"blank": false
}
]
},
{
"page": 3,
"title": "By the Numbers",
"lines": [
{
"text": "52 years of the Sunday crossword, always in pen.",
"facts": [
"F2"
],
"blank": false
},
{
"text": "14 birdhouses, built for the neighbours since spring.",
"facts": [
"F9"
],
"blank": false
},
{
"text": "7 a.m. sharp: his birthday call to every grandkid.",
"facts": [
"F10"
],
"blank": false
}
]
},
"... pages 4, 5, 6, 7, 8"
],
"asks": [],
"caveats": [
"Double-check that the squirrels' names (Biscuit, Clover, and Pete) are spelled the way Walt says them."
]
}
task: issue - Priya turns 30 - pad
Input:
{
"task": "issue",
"today": "2026-10-19",
"name": "Priya",
"age": "30",
"birthday": "2026-11-02",
"from": "The Tuesday trivia team",
"tone": "playful",
"masthead": "(choose one)",
"facts": "[{\"id\":\"F1\",\"kind\":\"quote\",\"text\":\"\\\"I'm not lost, I'm exploring.\\\"\"},{\"id\":\"F2\",\"kind\":\"moment\",\"text\":\"Ran her first 10K in April and stopped for a pastry at kilometre 7\"},\"... 3 more\"]",
"withheld": "1",
"off_limits": "(none)",
"page_plan": "[{\"page\":1,\"kind\":\"cover\",\"facts\":[\"F3\",\"F2\",\"F1\"]},{\"page\":2,\"kind\":\"letter\",\"facts\":[\"F1\"]},\"... 6 more\"]",
"page_reads": "[{\"level\":\"info\",\"code\":\"thin\",\"text\":\"5 usable facts - pages without one get fill-in blanks for the guests. 8 or more fill the issue.\"},{\"level\":\"info\",\"code\":\"empty_pages\",\"text\":\"Nothing in your facts fits pages 5, 8 yet.\"}]",
"page_verdict": "pad",
"notes": "She hates being the centre of attention, so keep it fond."
}
Reply:
{
"task": "issue",
"verdict": "pad",
"masthead": "43 Tabs Weekly",
"issue_line": "The 30 Issue",
"summary": "This runs as pad: the 10K-and-pastry moment, the 43 open tabs, the daily sourdough chats, the karaoke replay, and her own \"not lost, exploring\" line carry the cover, letter, numbers, feature, quiz and classifieds. Pages 5 and 8 have no fact to stand on yet, so they are left as blanks for the party to fill in. A moment from earlier in the year or a plan for the year ahead would round it out.",
"cover": {
"headline": "Priya Turns Thirty, Still Exploring",
"lines": [
{
"text": "43 tabs. Always open. Never closed.",
"move": "number-as-hero",
"facts": [
"F3"
]
},
{
"text": "Kilometre 7: she paused her 10K for a pastry.",
"move": "picture",
"facts": [
"F2"
]
},
"..."
]
},
"pages": [
{
"page": 2,
"title": "Editor's Letter",
"lines": [
{
"text": "Happy birthday, Priya!",
"facts": [],
"blank": false
},
{
"text": "Not lost, just exploring - and turning 30 while doing it.",
"facts": [
"F1"
],
"blank": false
},
{
"text": "With love, The Tuesday trivia team",
"facts": [],
"blank": false
}
]
},
{
"page": 3,
"title": "By the Numbers",
"lines": [
{
"text": "43 tabs open in her browser - at all times.",
"facts": [
"F3"
],
"blank": false
},
{
"text": "10K: the distance of her first run, in April.",
"facts": [
"F2"
],
"blank": false
},
{
"text": "3: times she sang the same karaoke song in a row.",
"facts": [
"F5"
],
"blank": false
}
]
},
"... pages 4, 5, 6, 7, 8"
],
"asks": [
"A specific moment from earlier in the year, for page 5's feature.",
"A plan or hope for the year ahead, for page 8's forecast.",
"Another love or habit, to round out the forecast page."
],
"caveats": [
"Double-check the exact wording of her \"I'm not lost, I'm exploring\" line before printing."
]
}
task: issue - Dana turns 45 - hold
Input:
{
"task": "issue",
"today": "2026-10-19",
"name": "Dana",
"age": "45",
"birthday": "(not given)",
"from": "The office",
"tone": "playful",
"masthead": "(choose one)",
"facts": "[{\"id\":\"F4\",\"kind\":\"quote\",\"text\":\"Drinks tea from a mug that says World's Okayest Boss\"}]",
"withheld": "3",
"off_limits": "the old job",
"page_plan": "[{\"page\":1,\"kind\":\"cover\",\"facts\":[\"F4\"]},{\"page\":2,\"kind\":\"letter\",\"facts\":[\"F4\"]},\"... 6 more\"]",
"page_reads": "[{\"level\":\"fix\",\"code\":\"too_few\",\"text\":\"Only 1 usable fact - an issue needs at least 4 to fill the pages without making things up.\"},{\"level\":\"info\",\"code\":\"no_moment\",\"text\":\"No moments from this year - the feature runs on them.\"}]",
"page_verdict": "hold",
"notes": ""
}
Reply:
{
"task": "issue",
"verdict": "hold",
"masthead": "Dana Daily",
"issue_line": "The 45 Issue",
"summary": "Only one usable fact came through so far - the mug that says World's Okayest Boss - which is not enough to fill an eight-page issue without making things up. That one line can anchor the cover once more comes in. A quote Dana says often, a moment from this year, a number, and a plan for the year ahead would fill out the rest.",
"cover": {
"headline": "Dana Turns 45",
"lines": []
},
"pages": [],
"asks": [
"A line Dana always says",
"A moment from this year",
"A number that matters to her",
"..."
],
"caveats": []
}
Build the input yourself
Load the script in node (global.window = {}; require("./issuekit.js")), then:
const K = IssueKit;
const st = K.state({
today: "2026-10-19", name: "Sam", age: "40", birthday: "2026-12-02", from: "The book club",
tone: "playful", masthead: "", off: "work", notes: "",
facts: "quote: \"Five more minutes.\"\nmoment: Finished a 1,000-piece puzzle in one weekend\nhabit: Reads the last page first"
});
console.log(st.verdict, st.facts.map(f => f.id + " " + f.kind + " " + f.status), st.plan);
const { input } = K.buildInput(st, { today: "2026-10-19" });
const wire = K.toWire(input); // POST this to /estimate and /run
// later: const reply = K.normalize(K.parseReply(job.output.output));
// const { notes, flags } = K.check(reply, st);
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://birthday-issue.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://birthday-issue.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"]
// birthday-issue.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://birthday-issue.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://birthday-issue.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
}
// BirthdayIssue.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 BirthdayIssue {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://birthday-issue.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://birthday-issue.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://birthday-issue.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://birthday-issue.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":"birthday-issue"}' | 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": "birthday-issue"})["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: "birthday-issue" })).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": "birthday-issue"}, 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", "birthday-issue"), 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: "birthday-issue" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "birthday-issue"])["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 = "birthday-issue" }))!["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": "issue", "today": "2026-10-19", "name": "Walt", "age": "80", "tone": "grand", "facts": "[{\"id\":\"F1\", ...}]", "page_plan": "[...]", "page_reads": "[...]", "page_verdict": "print"}
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": "issue", "today": "2026-10-19", "name": "Walt", "age": "80", "tone": "grand", "facts": "[{\"id\":\"F1\", ...}]", "page_plan": "[...]", "page_reads": "[...]", "page_verdict": "print"}
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: "issue", today, name, age, birthday, from, tone, masthead, facts, withheld, off_limits, page_plan, page_reads, page_verdict, notes?}
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 birthday-issue:issue:<inputHash>:a<attempt>, 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="birthday-issue:issue:$(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 = "birthday-issue:" + 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 lane's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `birthday-issue:${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 := "birthday-issue:" + 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 = "birthday-issue:" + 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 = "birthday-issue:#{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 = "birthday-issue:" . $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 = $"birthday-issue:{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: birthday-issue:issue:$(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": "issue", "today": "2026-10-19", "name": "Walt", "age": "80", "tone": "grand", "facts": "[{\"id\":\"F1\", ...}]", "page_plan": "[...]", "page_reads": "[...]", "page_verdict": "print"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "birthday-issue:issue:" + 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": `birthday-issue:issue:${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", "birthday-issue:issue:"+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", "birthday-issue:issue:" + 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" => "birthday-issue:issue:#{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: birthday-issue:issue:" . 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", $"birthday-issue:issue:{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);