← Nights to Release
Nights to Release API
Tokens

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.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field such as task or entries is missing.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: the read is metered and needs a personal token.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. 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:

OrderVerdictWhen
1thinFewer than 2 entries. entries may be empty; 2-4 questions.
2all-inTOTAL <= CAP. Every call is play.
3long-shotCAP < 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.
4trimOtherwise. 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

FieldTypeNotes
taskstringplan.
next_titlestringWhat is coming out, up to 100 characters.
next_notestringWhat is known about it (sequel, remake, fresh start), up to 400 characters.
release, startstringYYYY-MM-DD; the nights run from start up to, not including, release.
hours_week, nightsstringHours a week (0.5-80) and nights a week (1-7).
orderstringrelease, story or listed.
entriesstring (JSON)Array of {id, title, year, kind, hours, hours_estimated, note}, ids E1.. in list order, up to 40.
page_total_hours, page_capacity_hoursstringTOTAL and CAP.
page_nights, page_hours_per_night, page_recap_hoursstringThe nights, the hours each holds, and what a recap counts as (0.5).
page_leanstringThe page's verdict by the rules above.
page_flagsstring (JSON)Array of the page's warnings, such as the same title listed twice.
retry_notestringOnly 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"}
}

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')

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}' 

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}' 

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}' 

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