← Light Left
API

Drive Light Left from your own code

The one metered task, card, is available over HTTP. It takes the people for a card (a name and a birth month each), the card's date, the latitude they look up from, a short note, and the page's facts (each person's matched star with its distance and error bar, when its light left, how close the match is, its brightness and colour, its evening month and how high it stands). It returns one verdict (true_light, mostly_true or stand_ins), a title and a headline, a caption and a finding line per person, a footnote and a closing line to read under the sky.

The division of labour first: the page owns the star list, every match, every number, the tags and the order; the model owns only the wording; the page then re-checks the reply against its own matches. Nobody can buy or name a star.

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":"light-left"}), 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 (task, people, card_date, looking_from, note, page_facts) 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: writing a card 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 and the verdicts

Every run sends task: card (the one task is card); a missing or different one is still answered as card. The verdict follows a fixed check order on the page's tags, stopping at the first that fits: true_light when every person is tagged on_the_year (their age falls inside the star's error bar, or within half a year of its distance); mostly_true when at least half are; otherwise stand_ins. A person tagged near, stand_in or still_coming (younger than the nearest star's light) never counts.

Input (every field is a string)

FieldRequiredWhat it holds
taskyesAlways card.
peopleyesOne person per line: <number> | <name> | born <month year>, up to 8.
card_dateyesThe card's date, YYYY-MM-DD; ages and light travel are measured to it.
looking_fromyesThe latitude band they look up from, such as 40 N or 30 S.
noteyesThe person's own words on who the card is for, at least 3; over 1,200 characters it keeps its start and end with the middle marked as cut.
page_factsyesOne key | ... per line: people, card_date, looking_from, one star_<n> line per person (the star and its designation and constellation, light-years ± error, the age, the month the light left and the window, the years off, the tag, brightness and colour, the evening month, and its height and direction), then tags, page_verdict and verdict_rule.
clip_notenoWhat the page shortened, if anything.
retry_notenoOnly on the page's one reformat retry, after an unusable reply.

Reply (one JSON object)

{"task": "card", "title": "...", "verdict": "mostly_true", "verdict_reason": "...", "headline": "...",
 "stars": [{"n": 1, "star": "Capella", "caption": "...", "find": "..."}], "footnote": "...", "sky_line": "..."}
FieldWhat it holds
verdict, verdict_reasonThe verdict id by the check order above; at most 30 words on what decided it.
starsExactly one entry per person, in order: n, the page's star name (or none yet), a caption of at most 24 words (the person, the star, the distance with its error and when the light left, said honestly for a near or stand-in match) and a find line of at most 22 words (the evening month, the direction and height, the brightness - or that it never rises from there).
headline, titleA hook line of at most 20 words with the counts, and a constellation-style title of at most 8 words from the note's own words.
footnote, sky_lineOne or two honesty points (at most 40 words; the off-by point whenever a match is near or a stand-in) and a closing line of at most 20 words.

The page then checks the reply (LightKit.check): the verdict against its order, one entry per person with the page's own star, every caption naming its person without calling a match exact or a star someone's, every finding line naming the evening month (or none for a star that never rises), every number in the prose, names the input never uses, leaked field ids and the word limits.

Worked examples

These are the four free samples on the page, all dated 2026-10-23: the exact input and a stand-in model's reply, unedited. Together they cover all three verdicts and all four tags - two sisters and their mum all on the year (true light), a family of four with one near match (mostly true), a gran with three grandchildren including a near match, a stand-in and a toddler whose light is still on its way (stand-ins), and one person with a far-southern star picked by hand that never rises where they live (true light).

Two sisters and their mum, all on the year - true light

Input:

{
 "task": "card",
 "people": "1 | Ana | born January 1990\n2 | Bea | born July 2001\n3 | Mum | born January 1951",
 "card_date": "2026-10-23",
 "looking_from": "40 N",
 "note": "A card for Mum's birthday, from Ana and Bea.",
 "page_facts": "people | 3\ncard_date | 2026-10-23\nlooking_from | 40 N\nstar_1 | Arcturus (Alpha Bootis), in Bootes | 36.72 ± 0.22 light-years | Ana is 36.8 years old | the light arriving now left about February 1990 (window 1989 to 1990) | off by 0.0 years | tag: on_the_year | magnitude 0.1: one of the brightest stars in the sky | colour: orange | best evening sky: around June | highest toward the south, about 69 degrees up (7 fists at arm's length)\nstar_2 | Vega (Alpha Lyrae), in Lyra | 25.04 ± 0.07 light-years | Bea is 25.3 years old | the light arriving now left about October 2001 (window 2001 to 2002) | off by 0.2 years | tag: on_the_year | magnitude 0.1: one of the brightest stars in the sky | colour: white | best evening sky: around August | highest nearly overhead, about 89 degrees up (9 fists at arm's length)\nstar_3 | Zubenelgenubi (Alpha Librae), in Libra | 75.80 ± 0.33 light-years | Mum is 75.8 years old | the light arriving now left about January 1951 (window 1950 to 1951) | off by 0.0 years | tag: on_the_year | magnitude 2.8: clear from a suburb | colour: white | best evening sky: around June | highest toward the south, about 34 degrees up (3 fists at arm's length)\ntags | on_the_year 3; near 0; stand_in 0; still_coming 0\npage_verdict | true_light\nverdict_rule | true_light when every person is tagged on_the_year; else mostly_true when at least half are; else stand_ins"
}

Reply:

{
 "task": "card",
 "title": "A Birthday Light for Mum",
 "verdict": "true_light",
 "verdict_reason": "All 3 people are on the year, so every person counts - the first check, true light, decided it.",
 "headline": "3 people on this card, and all 3 are on the year.",
 "stars": [
  {
   "n": 1,
   "star": "Arcturus",
   "caption": "For Ana, Arcturus: 36.72 ± 0.22 light-years away, its light set out around when Ana was born, about February 1990.",
   "find": "Around June, in the evening, it stands toward the south, about 69 degrees up, one of the brightest stars in the sky."
  },
  {
   "n": 2,
   "star": "Vega",
   "caption": "For Bea, Vega: 25.04 ± 0.07 light-years away, its light set out around when Bea was born, about October 2001.",
   "find": "Around August, in the evening, it stands nearly overhead, about 89 degrees up, one of the brightest stars in the sky."
  },
  {
   "n": 3,
   "star": "Zubenelgenubi",
   "caption": "For Mum, Zubenelgenubi: 75.80 ± 0.33 light-years away, its light set out around when Mum was born, about January 1951.",
   "find": "Around June, in the evening, it stands toward the south, about 34 degrees up, clear from a suburb."
  }
 ],
 "footnote": "These distances come from measured parallaxes and carry the error shown. A light-year is a distance, so a star that many light-years away shows light that left that many years ago.",
 "sky_line": "A birthday card for Mum, from Ana and Bea - sky, light and stars."
}

A family of four, one near match - mostly true

Input:

{
 "task": "card",
 "people": "1 | Dad | born March 1984\n2 | Mum | born November 1986\n3 | Leo | born October 2013\n4 | Ivy | born February 2018",
 "card_date": "2026-10-23",
 "looking_from": "50 N",
 "note": "Our family of four, for the hallway wall.",
 "page_facts": "people | 4\ncard_date | 2026-10-23\nlooking_from | 50 N\nstar_1 | Capella (Alpha Aurigae), in Auriga | 42.80 ± 0.26 light-years | Dad is 42.6 years old | the light arriving now left about January 1984 (window 1983 to 1984) | off by 0.2 years | tag: on_the_year | magnitude 0.2: one of the brightest stars in the sky | colour: orange | best evening sky: around January | highest toward the south, about 86 degrees up (9 fists at arm's length), and never sets from 50 N\nstar_2 | Lambda Serpentis, in Serpens | 39.54 ± 0.15 light-years | Mum is 39.9 years old | the light arriving now left about April 1987 (window 1986 to 1987) | off by 0.4 years | tag: on_the_year | magnitude 4.5: faint - needs a darker sky | colour: yellow | best evening sky: around July | highest toward the south, about 47 degrees up (5 fists at arm's length)\nstar_3 | Procyon (Alpha Canis Minoris), in Canis Minor | 11.46 ± 0.05 light-years | Leo is 13.0 years old | the light arriving now left about May 2015 (window 2014 to 2015) | off by 1.6 years | tag: near | magnitude 0.5: one of the brightest stars in the sky | colour: yellow-white | best evening sky: around March | highest toward the south, about 45 degrees up (5 fists at arm's length)\nstar_4 | Sirius (Alpha Canis Majoris), in Canis Major | 8.60 ± 0.04 light-years | Ivy is 8.7 years old | the light arriving now left about March 2018 (window 2017 to 2018) | off by 0.1 years | tag: on_the_year | magnitude -1.1: one of the brightest stars in the sky | colour: white | best evening sky: around February | highest toward the south, about 23 degrees up (2 fists at arm's length)\ntags | on_the_year 3; near 1; stand_in 0; still_coming 0\npage_verdict | mostly_true\nverdict_rule | true_light when every person is tagged on_the_year; else mostly_true when at least half are; else stand_ins"
}

Reply:

{
 "task": "card",
 "title": "Our Family of Four Stars",
 "verdict": "mostly_true",
 "verdict_reason": "Not everyone is on the year, but 3 of 4 are at least half, so this is mostly true, not true light or stand-ins.",
 "headline": "4 people on this card, and 3 of them are on the year.",
 "stars": [
  {
   "n": 1,
   "star": "Capella",
   "caption": "For Dad, Capella: 42.80 ± 0.26 light-years away, its light set out around January 1984, around when Dad was born.",
   "find": "Around January evenings, about 86 degrees up in the south, never sets from there - one of the brightest stars."
  },
  {
   "n": 2,
   "star": "Lambda Serpentis",
   "caption": "For Mum, Lambda Serpentis: 39.54 ± 0.15 light-years away, its light set out around April 1987, around when Mum was born.",
   "find": "Around July evenings, about 47 degrees up in the south, faint - needs a darker sky."
  },
  {
   "n": 3,
   "star": "Procyon",
   "caption": "For Leo, Procyon is 11.46 ± 0.05 light-years away; its light set out around May 2015, the closest match, off by 1.6 years.",
   "find": "Around March evenings, about 45 degrees up in the south - one of the brightest stars."
  },
  {
   "n": 4,
   "star": "Sirius",
   "caption": "For Ivy, Sirius: 8.60 ± 0.04 light-years away, its light set out around March 2018, around when Ivy was born.",
   "find": "Around February evenings, about 23 degrees up in the south - one of the brightest stars."
  }
 ],
 "footnote": "The distances come from measured parallaxes and carry the error shown, and the near match is off by the years shown.",
 "sky_line": "Our family of four - Dad, Mum, Leo, Ivy - one family, carried across the years in starlight."
}

A gran and three grandchildren: a near match, a stand-in and light still on its way - stand-ins

Input:

{
 "task": "card",
 "people": "1 | Gran | born February 1964\n2 | Sam | born January 2012\n3 | Mia | born April 2020\n4 | Jo | born June 2024",
 "card_date": "2026-10-23",
 "looking_from": "30 S",
 "note": "Gran and her three grandchildren, for the fridge door.",
 "page_facts": "people | 4\ncard_date | 2026-10-23\nlooking_from | 30 S\nstar_1 | 110 Herculis, in Hercules | 62.65 ± 0.30 light-years | Gran is 62.7 years old | the light arriving now left about February 1964 (window 1963 to 1964) | off by 0.0 years | tag: on_the_year | magnitude 4.3: faint - needs a darker sky | colour: yellow-white | best evening sky: around August | highest toward the north, about 39 degrees up (4 fists at arm's length)\nstar_2 | Altair (Alpha Aquilae), in Aquila | 16.73 ± 0.05 light-years | Sam is 14.8 years old | the light arriving now left about January 2010 (window 2009 to 2010) | off by 2.0 years | tag: near | magnitude 0.8: one of the brightest stars in the sky | colour: white | best evening sky: around September | highest toward the north, about 51 degrees up (5 fists at arm's length)\nstar_3 | Sirius (Alpha Canis Majoris), in Canis Major | 8.60 ± 0.04 light-years | Mia is 6.5 years old | the light arriving now left about March 2018 (window 2017 to 2018) | off by 2.1 years | tag: stand_in | magnitude -1.1: one of the brightest stars in the sky | colour: white | best evening sky: around February | highest toward the north, about 77 degrees up (8 fists at arm's length)\nstar_4 | none yet | Jo is 2.4 years old, younger than the light of the nearest star on the list (Rigil Kentaurus, 4.32 ± 0.02 light-years) | the light that left it in June 2024 arrives about October 2028 | tag: still_coming\ntags | on_the_year 1; near 1; stand_in 1; still_coming 1\npage_verdict | stand_ins\nverdict_rule | true_light when every person is tagged on_the_year; else mostly_true when at least half are; else stand_ins"
}

Reply:

{
 "task": "card",
 "title": "Gran and Three Grandchildren Light",
 "verdict": "stand_ins",
 "verdict_reason": "Not every person is on the year, and only 1 of 4 is - fewer than half - so the verdict is stand-ins.",
 "headline": "4 people on this card, and 1 of them is on the year so far.",
 "stars": [
  {
   "n": 1,
   "star": "110 Herculis",
   "caption": "For Gran, 110 Herculis, 62.65 ± 0.30 light-years away - the light set out around when Gran was born, in February 1964.",
   "find": "Around August, in the evening, toward the north, about 39 degrees up - yellow-white and faint, needing a darker sky."
  },
  {
   "n": 2,
   "star": "Altair",
   "caption": "For Sam, Altair, 16.73 ± 0.05 light-years away - the closest match, its light set out around January 2010, off by 2.0 years.",
   "find": "Around September, in the evening, toward the north, about 51 degrees up - white and one of the brightest stars in the sky."
  },
  {
   "n": 3,
   "star": "Sirius",
   "caption": "For Mia, Sirius, 8.60 ± 0.04 light-years away - a stand-in, the nearest match, light set out around March 2018, off by 2.1 years.",
   "find": "Around February, in the evening, toward the north, about 77 degrees up - white and one of the brightest stars in the sky."
  },
  {
   "n": 4,
   "star": "none yet",
   "caption": "For Jo, no star yet - light from Rigil Kentaurus that left when Jo was born arrives around October 2028.",
   "find": "Light arrives around October 2028."
  }
 ],
 "footnote": "The near and stand-in matches are off by the years shown, and the distances come from measured parallaxes, each carrying the error shown alongside it.",
 "sky_line": "Gran, Sam, Mia and Jo - light, for the fridge door."
}

One person, a far-southern star picked by hand that never rises at 50 N - true light

Input:

{
 "task": "card",
 "people": "1 | Noor | born October 1998",
 "card_date": "2026-10-23",
 "looking_from": "50 N",
 "note": "Just me, a small card for my desk.",
 "page_facts": "people | 1\ncard_date | 2026-10-23\nlooking_from | 50 N\nstar_1 | Zeta Tucanae, in Tucana | 28.01 ± 0.04 light-years | Noor is 28.0 years old | the light arriving now left about October 1998 (window 1998 to 1999) | off by 0.0 years | tag: on_the_year (picked by hand) | magnitude 4.3: faint - needs a darker sky | colour: yellow-white | best evening sky: around November | never rises from 50 N\ntags | on_the_year 1; near 0; stand_in 0; still_coming 0\npage_verdict | true_light\nverdict_rule | true_light when every person is tagged on_the_year; else mostly_true when at least half are; else stand_ins"
}

Reply:

{
 "task": "card",
 "title": "A Small Desk Star",
 "verdict": "true_light",
 "verdict_reason": "True light, because all 1 person on the card is on the year.",
 "headline": "1 person on this card, and 1 is on the year.",
 "stars": [
  {
   "n": 1,
   "star": "Zeta Tucanae",
   "caption": "For Noor, Zeta Tucanae is 28.01 ± 0.04 light-years away; its light set out around October 1998, about when Noor was born.",
   "find": "Zeta Tucanae never rises from 50 N; its light left around when Noor was born, all the same."
  }
 ],
 "footnote": "The distance comes from a measured parallax and carries the error shown. Nobody can buy or name a star.",
 "sky_line": "Just a small star of light for Noor's desk."
}

Build the input yourself

Load the scripts in node (global.window = global; require("./stars.js"); require("./lightkit.js")), then:

const K = LightKit;
const built = K.buildInput({ as_of: "2026-10-23", lat: "n40", note: "A card for Mum's birthday, from Ana and Bea.",
  people: [{ name: "Ana", year: 1990, month: 1 }, { name: "Bea", year: 2001, month: 7 }] });
built.matches[0].star.name;                      // "Arcturus" - 36.72 ± 0.22 light-years
built.matches[0].tag;                            // "on_the_year"
built.matches[0].sky;                            // { month: "June", top: 69.x, dir: "toward the south", ... }
built.verdict;                                   // "true_light" - the order applied to the tags
const wire = K.toWire(built.input);              // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes                   // the page's re-check of the reply
// K.csv(built, reply)                           // the matches and the card's words as CSV

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://light-left.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":"light-left"}' | 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": "card", "people": "1 | Ana | born January 1990", "card_date": "2026-10-23", "looking_from": "40 N", "note": "...", "page_facts": "..."} - see the field table
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 light-left:<task>:<inputHash>:a<attempt> (the one task is card), 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="light-left:card:$(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: light-left:card:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json