← Year Reel
Year Reel API
Tokens

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.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field such as task or log 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 reel 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: 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:

OrderLengthWhenCards
1too-thinFewer 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.
2shortFewer than 20 entries, or fewer than 4 months with an entry.5: opening, three middle cards, closing. A persona object, no persona card.
3fullEverything 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

FieldTypeNotes
taskstringreel.
whatstringWhat the log records, up to 60 characters (Gigs I went to).
yearstring2026 or 2025; undated-year lines are read as this year, lines dated in another year are left out.
tonestringwarm, deadpan or hype.
logstringThe 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_factsstringThe counted facts, one per line: F6 Total spent across 11 entries: $385.
page_reelstringThe length the counts give, by the rules above.
page_countsstringentries 11; months 9; undated lines left out 0; other-year lines left out 0.
retry_notestringOnly 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"}
}

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

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

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

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