Drive Year Reel from your own code
The one metered run is available over HTTP: send a dated log, what it records, the year, a tone and the page's counted facts, and it returns a reel length, a title, a year type, 3, 5 or 8 story cards (each citing the facts it uses) and a share caption.
The division of labour first: the page counts; the model writes; the page re-checks the writing. Reading the dates, the numbered facts (entries, months, the busiest month, streaks, gaps, the weekday, totals per unit, the most repeated items and tags) and the reel length are arithmetic in reelkit.js, free and in the browser. The model chooses which facts become cards and words them; it may not count, and every digit it writes must already be in the facts or the log. Build the input with the same script the page uses (see Build the input yourself) so your facts match the page's.
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":"year-reel"}), 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 such as task or log 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: the reel 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
Every run sends task: reel - there is one lane. The reel length is decided by three ordered, non-overlapping rules on the page's counts; the first that matches wins:
| Order | Length | When | Cards |
|---|---|---|---|
| 1 | too-thin | Fewer than 6 dated entries in the year. | 3: opening, the best fact there is, closing. persona is null; asks lists 1 to 3 things to add. |
| 2 | short | Fewer than 20 entries, or fewer than 4 months with an entry. | 5: opening, three middle cards, closing. A persona object, no persona card. |
| 3 | full | Everything else. | 8: opening, five middle cards (a top list and a month card when those facts exist), a persona card, closing. |
The declared input schema carries scalars only, so every value is a string (ReelKit.toWire does this).
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | reel. |
what | string | What the log records, up to 60 characters (Gigs I went to). |
year | string | 2026 or 2025; undated-year lines are read as this year, lines dated in another year are left out. |
tone | string | warm, deadpan or hype. |
log | string | The log as typed, one entry per line. Past 6,000 characters whole lines are cut from the middle with a marker; the facts still count every line. |
page_facts | string | The counted facts, one per line: F6 Total spent across 11 entries: $385. |
page_reel | string | The length the counts give, by the rules above. |
page_counts | string | entries 11; months 9; undated lines left out 0; other-year lines left out 0. |
retry_note | string | Only on a reformat retry. |
The reply
The job's output.output is a string holding one JSON object: task, reel, title, persona ({name, why} or null), cards ([{type, kicker, headline, big, unit, list, caption, facts}], where type is one of intro, big-number, top-list, superlative, month, persona, outro; big is digits or empty; list is [{label, value}] on a top-list card; facts holds fact ids like F3), asks (only for too-thin) and share_caption. Parse it with ReelKit.normalize(ReelKit.parseReply(text)) and re-check it with ReelKit.check(reply, form): that flags a length that differs from the count, a card count that does not fit the length, an unknown fact id, a big number its cited fact does not hold, and any number that is in neither the facts nor the log.
Worked examples
Replies produced against the app's prompt for the five samples (made-up logs from made-up people; long fields and lists shortened; the full replies ship in example-result.js). Together they cover every reel length.
runs with ISO dates and km - full
Input:
{
"task": "reel",
"what": "My runs",
"year": "2026",
"tone": "warm",
"log": "2026-01-03 | New year shakeout | 5 km | #easy\n2026-01-10 | Saturday 5k in the park | 5 km | #park\n2026-01-24 | Riverside loop | 8 km | #easy ... (1425 characters)",
"page_facts": "F1 Entries logged in 2026: 29\nF2 Months with at least one entry: 10 of 12\nF3 Busiest month: October (4)\nF4 Quietest months with an entry (a tie): February and July (2 each)\nF5 Different things logged: ... (792 characters)",
"page_reel": "full",
"page_counts": "entries 29; months 10; undated lines left out 0; other-year lines left out 0"
}
Reply:
{
"task": "reel",
"reel": "full",
"title": "Your 2026 Year in Running",
"persona": {
"name": "The Saturday Regular",
"why": "You logged 20 of your 29 entries on a Saturday, your steady day to run."
},
"cards": [
{
"type": "intro",
"kicker": "2026",
"headline": "Your year of running",
"big": "",
"unit": "",
"list": [],
"caption": "Here's your year, told through every run you logged.",
"facts": []
},
{
"type": "month",
"kicker": "Your peak month",
"headline": "October was your busiest month",
"big": "4",
"unit": "",
"list": [],
"caption": "You logged 4 runs in October, more than any other month this year.",
"facts": [
"F3"
]
},
{
"type": "big-number",
"kicker": "Your total distance",
"headline": "255.1 km covered this year",
"big": "255.1",
"unit": "km",
"list": [],
"caption": "Across 29 entries this year, you covered 255.1 km in total.",
"facts": [
"F6"
]
},
"... (8 cards in all)"
],
"asks": [],
"share_caption": "255.1 km across 29 runs this year, capped by a 21.1 km half marathon - your first ever."
}
books under month headers - full
Input:
{
"task": "reel",
"what": "Books I read",
"year": "2026",
"tone": "deadpan",
"log": "January\n- The Salt Orchard, 312 pages #literary\n- Night Trains of the North, 288 pages #mystery\n- A Field Guide to Small Rooms, 190 pages #e ... (971 characters)",
"page_facts": "F1 Entries logged in 2026: 21\nF2 Months with at least one entry: 10 of 12\nF3 Busiest months (a tie): January, April and June (3 each)\nF4 Quietest months with an entry (a tie): May and July (1 each)\nF5 ... (476 characters)",
"page_reel": "full",
"page_counts": "entries 21; months 10; undated lines left out 0; other-year lines left out 0"
}
Reply:
{
"task": "reel",
"reel": "full",
"title": "Books I Read in 2026",
"persona": {
"name": "The Mystery Regular",
"why": "Mystery topped your tags, logged more often than any other kind of book."
},
"cards": [
{
"type": "intro",
"kicker": "2026",
"headline": "Books I Read in 2026",
"big": "",
"unit": "",
"list": [],
"caption": "A plain log, read back in order.",
"facts": []
},
{
"type": "big-number",
"kicker": "The whole year's log",
"headline": "You logged 21 books",
"big": "21",
"unit": "",
"list": [],
"caption": "No averages, no estimates. Just the books you wrote down.",
"facts": [
"F1"
]
},
{
"type": "big-number",
"kicker": "Pages, all of them",
"headline": "6975 pages turned",
"big": "6975",
"unit": "pages",
"list": [],
"caption": "Every page counted comes from entries already in the log.",
"facts": [
"F5"
]
},
"... (8 cards in all)"
],
"asks": [],
"share_caption": "21 books, 6975 pages, mystery winning the tag count. The log already did the bragging."
}
gigs with prices, 11 entries - short
Input:
{
"task": "reel",
"what": "Gigs I went to",
"year": "2026",
"tone": "hype",
"log": "Feb 14 - Tribute night at the Lantern Room - $18\nMar 6 - Local jazz trio, Blue Door cafe - $10\nMar 21 - Lantern Room open mic - $5\nApr 30 - ... (441 characters)",
"page_facts": "F1 Entries logged in 2026: 11\nF2 Months with at least one entry: 9 of 12\nF3 Busiest months (a tie): March and September (2 each)\nF4 Quietest months with an entry (a tie): 7 months tied at 1 each\nF5 Di ... (675 characters)",
"page_reel": "short",
"page_counts": "entries 11; months 9; undated lines left out 0; other-year lines left out 0"
}
Reply:
{
"task": "reel",
"reel": "short",
"title": "Your 2026 Gig Year, by the Numbers",
"persona": {
"name": "The Lantern Room Loyalist",
"why": "You returned to the Lantern Room open mic more than any other show — 3 times this year."
},
"cards": [
{
"type": "intro",
"kicker": "2026",
"headline": "Gigs I went to",
"big": "",
"unit": "",
"list": [],
"caption": "The whole year of shows you logged, start to finish.",
"facts": []
},
{
"type": "big-number",
"kicker": "Total spent on shows",
"headline": "You spent $385 on shows",
"big": "385",
"unit": "$",
"list": [],
"caption": "Show by show, your nights out added up to $385 this year.",
"facts": [
"F6"
]
},
{
"type": "superlative",
"kicker": "Biggest night out",
"headline": "Stadium show cost you $120",
"big": "120",
"unit": "$",
"list": [],
"caption": "Nosebleed seats, biggest single spend of the year — the stadium show on Oct 10.",
"facts": [
"F7"
]
},
"... (5 cards in all)"
],
"asks": [],
"share_caption": "My 2026 gig year: $385 spent, a $120 stadium night, and 3 trips back to the Lantern Room open mic."
}
films across only 3 months - short
Input:
{
"task": "reel",
"what": "Films I watched",
"year": "2026",
"tone": "deadpan",
"log": "08/02 The Quiet Field 104 min #drama\n08/05 Midnight Diner Lights 96 min #comedy\n08/07 The Quiet Field 104 min #drama\n08/12 Long Way Down the ... (949 characters)",
"page_facts": "F1 Entries logged in 2026: 24\nF2 Months with at least one entry: 3 of 12\nF3 Busiest month: October (11)\nF4 Quietest month with an entry: September (5)\nF5 Different things logged: 14\nF6 Total min acros ... (762 characters)",
"page_reel": "short",
"page_counts": "entries 24; months 3; undated lines left out 0; other-year lines left out 0"
}
Reply:
{
"task": "reel",
"reel": "short",
"title": "Films I Watched in 2026",
"persona": {
"name": "The Horror Loyalist",
"why": "Horror was your most logged tag this year, with 12 entries to its name."
},
"cards": [
{
"type": "intro",
"kicker": "2026",
"headline": "Films I watched",
"big": "",
"unit": "",
"list": [],
"caption": "No averages, no guesses. Just what you actually watched this year.",
"facts": []
},
{
"type": "top-list",
"kicker": "Repeat viewings",
"headline": "Titles you watched more than once",
"big": "",
"unit": "",
"list": [
{
"label": "The Quiet Field",
"value": "3"
},
{
"label": "Robot Gardener",
"value": "3"
},
{
"label": "Midnight Diner Lights",
"value": "2"
},
{
"label": "Long Way Down the Coast",
"value": "2"
},
{
"label": "Fog Over Harrow",
"value": "2"
}
],
"caption": "The titles you came back to more than once this year, in order.",
"facts": [
"F8"
]
},
{
"type": "month",
"kicker": "Busiest month",
"headline": "October was your busiest month",
"big": "11",
"unit": "",
"list": [],
"caption": "October: 11 entries, more than any other month this year.",
"facts": [
"F3"
]
},
"... (5 cards in all)"
],
"asks": [],
"share_caption": "24 films, 3 months, horror led the tag count. No ratings, no reviews, just the record."
}
four pottery classes - too-thin (needs more log)
Input:
{
"task": "reel",
"what": "Pottery class",
"year": "2026",
"tone": "warm",
"log": "12 Feb: first wheel class, one wobbly bowl\n19 Feb: centred the clay for the first time\n5 Mar: trimmed two bowls\n2 Apr: glaze day, the blue o ... (150 characters)",
"page_facts": "F1 Entries logged in 2026: 4\nF2 Months with at least one entry: 3 of 12\nF3 Busiest month: February (2)\nF4 Quietest months with an entry (a tie): March and April (1 each)\nF5 Longest gap between entries ... (340 characters)",
"page_reel": "too-thin",
"page_counts": "entries 4; months 3; undated lines left out 0; other-year lines left out 0"
}
Reply:
{
"task": "reel",
"reel": "too-thin",
"title": "Your 2026 pottery class, so far",
"persona": null,
"cards": [
{
"type": "intro",
"kicker": "2026",
"headline": "Pottery class",
"big": "",
"unit": "",
"list": [],
"caption": "Your pottery class year is just getting started.",
"facts": []
},
{
"type": "month",
"kicker": "Your busiest month",
"headline": "February was your busiest month",
"big": "2",
"unit": "",
"list": [],
"caption": "You showed up twice in February, more than any other month this year.",
"facts": [
"F3"
]
},
{
"type": "outro",
"kicker": "Onward",
"headline": "More clay days ahead",
"big": "",
"unit": "",
"list": [],
"caption": "There's plenty of room left on the wheel for the rest of the year.",
"facts": []
}
],
"asks": [
"Add a line each time you throw, trim, or glaze, with the date.",
"Add the classes after April, one dated line each.",
"Note what happened at your next class, like the first one did."
],
"share_caption": "My pottery class year is off to a good start, one wobbly bowl at a time."
}
Build the input yourself
Load the script in node (global.window = global; require("./reelkit.js")), then:
const K = ReelKit;
const form = { what: "Gigs I went to", year: "2026", tone: "hype",
log: "Feb 14 - Tribute night at the Lantern Room - $18\nMar 6 - Local jazz trio, Blue Door cafe - $10\n..." };
console.log(K.lean(form)); // the reel length and why
console.log(K.factText(K.facts(form).list)); // the numbered facts the model may use
const wire = K.toWire(K.buildInput(form).input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// const { notes } = K.check(reply, form); // the page's re-check of the reply
// K.plainText(reply) // the card script as plain text
// K.draft(form) // the free draft cards, built from the facts alone
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://year-reel.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://year-reel.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"]
// year-reel.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://year-reel.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://year-reel.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
}
// YearReel.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 YearReel {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://year-reel.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://year-reel.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://year-reel.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://year-reel.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":"year-reel"}' | 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": "year-reel"})["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: "year-reel" })).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": "year-reel"}, 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", "year-reel"), 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: "year-reel" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "year-reel"])["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 = "year-reel" }))!["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": "reel", "what": "Gigs I went to", "year": "2026", "tone": "hype", "log": "Feb 14 - Tribute night at the Lantern Room - $18\n...", "page_facts": "F1 Entries logged in 2026: 11\n...", "page_reel": "short", "page_counts": "entries 11; months 9; ..."}
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": "reel", "what": "Gigs I went to", "year": "2026", "tone": "hype", "log": "Feb 14 - Tribute night at the Lantern Room - $18\n...", "page_facts": "F1 Entries logged in 2026: 11\n...", "page_reel": "short", "page_counts": "entries 11; months 9; ..."}
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: "reel", what, year, tone, log, page_facts, page_reel, page_counts (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 year-reel:<task>:<inputHash>:a<attempt> (there is one lane, reel), 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="year-reel:reel:$(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 = "year-reel:" + 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 read as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `year-reel:${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 := "year-reel:" + 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 = "year-reel:" + 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 = "year-reel:#{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 = "year-reel:" . $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 = $"year-reel:{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: year-reel:reel:$(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": "reel", "what": "Gigs I went to", "year": "2026", "tone": "hype", "log": "Feb 14 - Tribute night at the Lantern Room - $18\n...", "page_facts": "F1 Entries logged in 2026: 11\n...", "page_reel": "short", "page_counts": "entries 11; months 9; ..."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "year-reel:reel:" + 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": `year-reel:reel:${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", "year-reel:reel:"+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", "year-reel:reel:" + 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" => "year-reel:reel:#{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: year-reel:reel:" . 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", $"year-reel:reel:{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);