Drive Nights to Release from your own code
The one metered read is available over HTTP: give it what came before a new release - films, seasons, games, books - with the release date and the hours you have, and it returns a verdict, a call for every entry (play, recap or skip) with a reason and, for a recap, a spoiler-safe focus, the order and why, the first entry, the one to keep if you only do one, and pacing - or, for a one-line list, the questions to answer first.
The division of labour first: the page owns the hours; the model makes the calls; the page re-checks the reply. Reading the list, the typical-length estimates, the nights between the start and release day, the hours they hold, the packing and the page's own verdict all run in the browser (catchkit.js) and travel in the run input. The page then checks the reply: a verdict the hours do not support, an entry with no call, a trimmed plan that still does not fit, both versions of a remake kept, a name your list never used.
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":"nights-to-release"}), 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 entries 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 read 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: plan - there is one lane. TOTAL is every entry at full length, CAP the hours the nights hold. The verdict is decided by four ordered, non-overlapping rules; the first that matches wins:
| Order | Verdict | When |
|---|---|---|
| 1 | thin | Fewer than 2 entries. entries may be empty; 2-4 questions. |
| 2 | all-in | TOTAL <= CAP. Every call is play. |
| 3 | long-shot | CAP < 0.25 x TOTAL. At most one entry is played in full (and only if it is at most 1.5 x CAP); other main entries are recapped, side stories skipped. |
| 4 | trim | Otherwise. Play hours plus 0.5 per recap must be at most CAP. |
The declared input schema carries scalars only, so array-valued fields travel JSON-encoded as strings (CatchKit.toWire does this). Every value is a string.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | plan. |
next_title | string | What is coming out, up to 100 characters. |
next_note | string | What is known about it (sequel, remake, fresh start), up to 400 characters. |
release, start | string | YYYY-MM-DD; the nights run from start up to, not including, release. |
hours_week, nights | string | Hours a week (0.5-80) and nights a week (1-7). |
order | string | release, story or listed. |
entries | string (JSON) | Array of {id, title, year, kind, hours, hours_estimated, note}, ids E1.. in list order, up to 40. |
page_total_hours, page_capacity_hours | string | TOTAL and CAP. |
page_nights, page_hours_per_night, page_recap_hours | string | The nights, the hours each holds, and what a recap counts as (0.5). |
page_lean | string | The page's verdict by the rules above. |
page_flags | string (JSON) | Array of the page's warnings, such as the same title listed twice. |
retry_note | string | Only on a reformat retry. |
The reply
The job's output.output is a string holding one JSON object: task, verdict, title, summary, order_kind (release, story or listed), order_why, entries ([{id, call, why, recap_focus}] - every entry once, in the order to go through them, skipped last; call is play, recap or skip), first_up and if_only_one (entry ids), pacing and questions (strings, only for thin).
Worked examples
Replies produced against the app's prompt for the four samples (made-up series; long fields and lists shortened; the full replies ship in example-result.js). Together they cover every verdict and every call.
three films and a short with room to spare - all-in
Input:
{
"task": "plan",
"next_title": "Lantern Keepers 4",
"next_note": "Picks up right after the third film: same family, new city.",
"release": "2026-11-14",
"start": "2026-10-24",
"hours_week": "5",
"nights": "2",
"order": "release",
"entries": "[{\"id\":\"E1\",\"title\":\"Lantern Keepers\",\"year\":\"2015\",\"kind\":\"film\",\"hours\":1.7,\"hours_estimated\":false,\"note\":\"where the family and the lantern shop start\"},{\"id\":\"E2\",\"title\":\"Lantern Keepers 2\",\"year\":\"2018\",\"kind\":\"fil ... (592 characters)",
"page_total_hours": "6",
"page_capacity_hours": "15",
"page_nights": "6",
"page_hours_per_night": "2.5",
"page_recap_hours": "0.5",
"page_lean": "all-in",
"page_flags": "[]"
}
Reply:
{
"task": "plan",
"verdict": "all-in",
"title": "Full Watch-Through Before Lantern Keepers 4",
"summary": "Your list is short enough to watch in full before Lantern Keepers 4 arrives - every entry gets a play. All four together come to 6 hours, well under the 15 hours your nights hold, so you've got room to spare. Watch them in release order starting with the first film, and keep Lantern Keepers 3 closest in mind, since it's the one Lantern Keepers 4 picks up right after.",
"order_kind": "release",
"order_why": "Release order already carries the family's story from the lantern shop to the move to the new city, so there's no reason to change it.",
"entries": [
{
"id": "E1",
"call": "play",
"why": "This is where the series begins, opening with the family and the lantern shop your notes mention.",
"recap_focus": ""
},
{
"id": "E2",
"call": "play",
"why": "Lantern Keepers 2 carries the family's story forward right after the first film, as the sisters leave home.",
"recap_focus": ""
},
{
"id": "E3",
"call": "play",
"why": "This holiday short is a quick side story that fits easily between Lantern Keepers 2 and Lantern Keepers 3.",
"recap_focus": ""
},
"... (4 in all)"
],
"first_up": "E1",
"if_only_one": "E4",
"pacing": "Every entry comes in under the 2.5 hours a night holds, so each one fits in a single sitting without needing to be split across nights. With hours well to spare, leave a few nights free as a buffer before Lantern Keepers 4 arrives.",
"questions": []
}
five games including a remake, six weeks - trim
Input:
{
"task": "plan",
"next_title": "Cinderwake IV",
"next_note": "Direct sequel to Cinderwake III. The trailer brings back the Warden from the first game.",
"release": "2026-12-04",
"start": "2026-10-24",
"hours_week": "8",
"nights": "4",
"order": "release",
"entries": "[{\"id\":\"E1\",\"title\":\"Cinderwake\",\"year\":\"2009\",\"kind\":\"game\",\"hours\":22,\"hours_estimated\":false,\"note\":\"the first one; the Warden's story starts here\"},{\"id\":\"E2\",\"title\":\"Cinderwake II\",\"year\":\"2012\",\"kind\":\"game\",\"hour ... (729 characters)",
"page_total_hours": "132",
"page_capacity_hours": "48",
"page_nights": "24",
"page_hours_per_night": "2",
"page_recap_hours": "0.5",
"page_lean": "trim",
"page_flags": "[\"Same title twice: Cinderwake (2009) and Cinderwake (2021) - an original and its remake? The plan should keep only one.\"]"
}
Reply:
{
"task": "plan",
"verdict": "trim",
"title": "Catching Up Before Cinderwake IV",
"summary": "Your list runs to 132 hours against the 48 your nights hold before Cinderwake IV arrives, so this is a trim. Cinderwake III gets the full 40 hours since IV picks up directly on its cliffhanger; Cinderwake II and the Ember Tactics spin-off get recaps since their ties to the new release are thin, and the 2009 Cinderwake gets a recap too, kept over its 2021 remake since the notes don't say which version the trailer's Warden callback means. That brings the plan to about 41.5 hours, under your 48-hour capacity, with room for a spare night before release day.",
"order_kind": "release",
"order_why": "Your chosen release order already runs from the 2009 original toward Cinderwake III's cliffhanger, so there's no reason to reorder it.",
"entries": [
{
"id": "E1",
"call": "recap",
"why": "The 2009 original is kept over the 2021 remake because your notes don't say which version the trailer's Warden callback points to and the original runs fewer hours (22 vs 25), so it gets a recap instead of a full replay.",
"recap_focus": "Watch for how the Warden is introduced and what they're working toward, since the trailer calls back to that character."
},
{
"id": "E2",
"call": "recap",
"why": "Its note calls it a new hero on a mostly separate story, so with the tie to Cinderwake IV left thin, a recap keeps it in the plan without crowding out Cinderwake III.",
"recap_focus": "Watch for how this hero's story runs apart from the Warden's, in case it connects back later."
},
{
"id": "E3",
"call": "recap",
"why": "It's a strategy spin-off and side story with no stated link to the new release, but there's still room after Cinderwake III for a recap rather than dropping it.",
"recap_focus": "Watch for how the side story's events relate, if at all, to the mainline Warden story."
},
"... (5 in all)"
],
"first_up": "E1",
"if_only_one": "E4",
"pacing": "Spread Cinderwake III's 40 hours over several nights since it's far longer than any one sitting, and group the three recaps together on a lighter night since each only takes about half an hour. The plan lands under the 48 hours your nights hold, so you can still leave a spare night free before release day.",
"questions": []
}
five seasons and a special, two weeks - long-shot
Input:
{
"task": "plan",
"next_title": "Tidewatch: The Film",
"next_note": "A feature film set after the series finale, with the same crew of harbor pilots.",
"release": "2026-11-07",
"start": "2026-10-24",
"hours_week": "4",
"nights": "2",
"order": "listed",
"entries": "[{\"id\":\"E1\",\"title\":\"Tidewatch season 1\",\"year\":\"2017\",\"kind\":\"season\",\"hours\":9,\"hours_estimated\":false,\"note\":\"the crew comes together\"},{\"id\":\"E2\",\"title\":\"Tidewatch season 2\",\"year\":\"2018\",\"kind\":\"season\",\"hours\":9,\" ... (821 characters)",
"page_total_hours": "46",
"page_capacity_hours": "8",
"page_nights": "4",
"page_hours_per_night": "2",
"page_recap_hours": "0.5",
"page_lean": "long-shot",
"page_flags": "[]"
}
Reply:
{
"task": "plan",
"verdict": "long-shot",
"title": "Catching Up on Tidewatch Before the Film",
"summary": "This is a long-shot: your list runs 46 hours but the nights before the film only hold 8, so most of it won't fit even trimmed down. Watch Tidewatch season 5 in full, since its own note says it sets up the film, and recap the four other seasons to keep the thread without the time a full watch would take. Skip the holiday special, since it's side content. Even so, the plan comes to 11 hours, 3 over what the nights hold, so expect part of it to spill past release day.",
"order_kind": "listed",
"order_why": "Keeping the list's own order moves you through the four seasons in sequence before the final season you're watching in full, with the holiday special set aside at the end.",
"entries": [
{
"id": "E1",
"call": "recap",
"why": "This season starts the series as the crew comes together, but it isn't the season the new film's note points to, so recap it to save the nights a full watch would need.",
"recap_focus": "Look for how the crew first comes together as a team, rather than the details of how it happens."
},
{
"id": "E2",
"call": "recap",
"why": "Your notes don't say how this season ties into the film, so judging by its position as the second season, a recap keeps it in the plan without the time a full watch would take.",
"recap_focus": "Since the notes don't say how this one connects, focus on where the crew ends up by the finale rather than how they get there."
},
{
"id": "E3",
"call": "recap",
"why": "This is the storm arc and the fan favourite, but it's not the season the film's note points to, so recap it to keep that highlight without the hours a full watch would take.",
"recap_focus": "Look for how the storm arc resolves for the crew, rather than the blow-by-blow of the storm itself."
},
"... (6 in all)"
],
"first_up": "E1",
"if_only_one": "E6",
"pacing": "Watch the final season in two-hour chunks across the nights you have, and pair two of the half-hour recaps onto one night where there's room, keeping each night at two hours or less. Because the plan's 11 hours run past the 8 the nights hold, expect part of the final season to spill past release day rather than planning a spare night.",
"questions": []
}
a book series named in one line - thin
Input:
{
"task": "plan",
"next_title": "The Glass Orchard, book 7",
"next_note": "",
"release": "2027-01-15",
"start": "2026-10-24",
"hours_week": "5",
"nights": "5",
"order": "listed",
"entries": "[{\"id\":\"E1\",\"title\":\"The Glass Orchard books 1-6\",\"year\":\"\",\"kind\":\"book\",\"hours\":10,\"hours_estimated\":true,\"note\":\"\"}]",
"page_total_hours": "10",
"page_capacity_hours": "60",
"page_nights": "60",
"page_hours_per_night": "1",
"page_recap_hours": "0.5",
"page_lean": "thin",
"page_flags": "[]"
}
Reply:
{
"task": "plan",
"verdict": "thin",
"title": "Before The Glass Orchard, Book 7",
"summary": "Your list only has one entry on it - The Glass Orchard books 1-6 - so there's nothing yet to weigh against anything else, and that's too thin to build a real plan from. Between now and book 7's release you've got about 60 nights at roughly an hour each, which is plenty of room, but I need to know what's actually left to get through before I can say what to play, recap or skip. Once I have that, and know how book 7 connects to what came before, I can put together an order and a pace.",
"order_kind": "listed",
"order_why": "Listed order is what your list already follows, and there's nothing in the notes pointing to a different order yet.",
"entries": [],
"first_up": "",
"if_only_one": "",
"pacing": "There's nothing to pace yet with just one entry on the list. Once I know what's really in it, I can fit the plan into the nights before release.",
"questions": [
"What's actually on your list before book 7 - one line each, with hours if you know them (books 1-6 individually, or however you'd split it)?",
"Are any of those side stories, specials or spin-offs, or just the main numbered books?",
"Is book 7 a direct sequel to book 6, a remake of an earlier one, or a fresh start you could jump into without catching up?"
]
}
Build the input yourself
Load the script in node (global.window = global; require("./catchkit.js")), then:
const K = CatchKit;
const form = { next_title: "Cinderwake IV", next_note: "Direct sequel to Cinderwake III.", release: "2026-12-04", start: "2026-10-24",
hours_week: "8", nights: "4", order: "release", entries: "Cinderwake (2009) | game | 22h\nCinderwake III (2018) | game | 40h" };
console.log(K.lean(form)); // the page's verdict, the hours listed and the hours the nights hold
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
// const plan = K.planOf(K.entriesOf(form), K.callsOf(reply), K.orderOf(reply));
// K.pack(plan, K.nights(form)).doors // the nights, each with what goes on it
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://nights-to-release.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://nights-to-release.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"]
// nights-to-release.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://nights-to-release.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://nights-to-release.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
}
// NightsToRelease.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 NightsToRelease {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://nights-to-release.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://nights-to-release.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://nights-to-release.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://nights-to-release.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":"nights-to-release"}' | 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": "nights-to-release"})["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: "nights-to-release" })).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": "nights-to-release"}, 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", "nights-to-release"), 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: "nights-to-release" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "nights-to-release"])["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 = "nights-to-release" }))!["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": "plan", "next_title": "Cinderwake IV", "next_note": "Direct sequel to Cinderwake III...", "release": "2026-12-04", "start": "2026-10-24", "hours_week": "8", "nights": "4", "order": "release", "entries": "[{\"id\":\"E1\",\"title\":\"Cinderwake\",\"year\":\"2009\",...}]", "page_total_hours": "132", "page_capacity_hours": "48", "page_lean": "trim", ...}
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": "plan", "next_title": "Cinderwake IV", "next_note": "Direct sequel to Cinderwake III...", "release": "2026-12-04", "start": "2026-10-24", "hours_week": "8", "nights": "4", "order": "release", "entries": "[{\"id\":\"E1\",\"title\":\"Cinderwake\",\"year\":\"2009\",...}]", "page_total_hours": "132", "page_capacity_hours": "48", "page_lean": "trim", ...}
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: "plan", next_title, release, start, entries, page_lean, ... (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 nights-to-release:<task>:<inputHash>:a<attempt> (there is one lane, plan), 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="nights-to-release:plan:$(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 = "nights-to-release:" + 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 = `nights-to-release:${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 := "nights-to-release:" + 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 = "nights-to-release:" + 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 = "nights-to-release:#{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 = "nights-to-release:" . $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 = $"nights-to-release:{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: nights-to-release:plan:$(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": "plan", "next_title": "Cinderwake IV", "next_note": "Direct sequel to Cinderwake III...", "release": "2026-12-04", "start": "2026-10-24", "hours_week": "8", "nights": "4", "order": "release", "entries": "[{\"id\":\"E1\",\"title\":\"Cinderwake\",\"year\":\"2009\",...}]", "page_total_hours": "132", "page_capacity_hours": "48", "page_lean": "trim", ...}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "nights-to-release:plan:" + 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": `nights-to-release:plan:${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", "nights-to-release:plan:"+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", "nights-to-release:plan:" + 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" => "nights-to-release:plan:#{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: nights-to-release:plan:" . 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", $"nights-to-release:plan:{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);