← Keep Lit
API
Tokens

Drive Keep Lit from your own code

The one metered task, call-list, is available over HTTP. It takes the as-of day, how many calls there is room for, and the list of people with the page's own day counts, and returns a verdict - call or all-lit - with today's picks (a reason, a one-line why, two talking points from the notes and an opener text for each), the people who wait, and the people the notes ask to hold off. A natural use is a morning reminder that reads a contacts sheet and posts today's three calls to a phone, or a family group tool that shares who is overdue for a call.

The division of labour first: the page owns the arithmetic; the model owns the judgement and the words; the page re-checks the reply. Reading the list, the days since each talk, the rhythm in days, the dates in the notes and how far off they are come from litkit.js, which runs in node as well as in the browser. The model never does date arithmetic: LitKit.buildInput hands it days_since, cadence_days and each date's in_days. LitKit.check holds each reply to the list: every ref real, nobody twice, the count respected, the fixed order kept, each overdue or drifting reason matching the day counts, nobody picked whose notes ask for space, and no number, capitalised name or he/she that the list does not hold.

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":"keep-lit"}), 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, today, people) 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: picking today's calls 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, the reasons and the verdicts

Every run sends task: call-list (the only task; a missing or unknown one is treated as call-list). Each person gets one reason by ordered, non-overlapping rules - the first that applies wins:

OrderReasonWhen
1heldThe notes ask for space or no calls, or say the person is away or busy until a date still ahead. Listed in held; never picked.
2owedThe notes say you owe them something: a call or reply back, or a thing you promised.
3dateA date in the notes is from 2 days ago to 14 days ahead (in_days -2 to 14).
4overduedays_since is empty, or at least cadence_days.
5driftingdays_since is at least three quarters of cadence_days.
6fineOtherwise - not in the reply.

Owed, date, overdue and drifting people form one queue in that order (inside a reason: the most overdue first; for dates, the soonest first). picks takes the first count; the rest go to later. The verdict is call when anyone is picked and all-lit when the queue is empty.

The declared input schema carries scalars only, so the array-valued fields (people, page_lean) travel JSON-encoded as strings (LitKit.toWire does this). Every value is a string.

Input fields

FieldTypeNotes
taskstringcall-list. Required.
todaystringThe as-of day, YYYY-MM-DD. Required.
countstring3, 5 or 7 - how many calls there is room for.
peoplestring (JSON)[{id, name, last, days_since, cadence_days, notes, dates}] - ids P1.., up to 40 people; days_since is "" when there is no usable last date; dates is [{text, in_days}]; notes cut at 240 characters. Required.
page_leanstring (JSON)[{ref, guess}] - the page's own guess from word lists, which the model judges for itself.
clip_note, retry_notestringWhat the page cut, when it cut something; and only on a reformat retry, what was wrong.

The reply

The job's output.output is a string holding one JSON object: task, verdict, title, summary, picks ([{ref, why, why_line, points, opener}] - points holds two talking points), later ([{ref, why}]) and held ([{ref, why_line}]). Parse with LitKit.parseReply, then normalize(reply), then check(reply, built) where built = LitKit.buildInput(form); ics(reply, built, form) writes the calendar file of call slots and md(reply, built, form) the Markdown.

Worked examples

Replies produced against the app's prompt for the three sample lists (made-up people; long fields shortened; the full replies ship in example-result.js). Together they cover both verdicts and every reason.

nine people, room for five: owed, date, overdue, drifting, one held - call

Input:

{
 "task": "call-list",
 "today": "2026-10-21",
 "count": "5",
 "people": "[{\"id\":\"P1\",\"name\":\"Maya Ortiz\",\"last\":\"2026-09-30\",\"days_since\":21,\"cadence_days\":21,\"notes\":\"I promised to send her the photos from the lake trip; started a new job in September\",\"dates\":[]},{\"id\":\"P2\",\"name\":\"Grandpa Lou\",\"last\":\"2026-10-04\",\"days_since\":17,\"cadence_days\":7,\"notes\":\"birthday Oct 26; likes hearing about the garden\",\"dates\":[{\"text\":\"Oct 26\",\"in_days\":5}]},{\"id\":\"P3\",\"name\":\"Dev\",\"last\":\"2026-07-12\" ... (1368 characters)",
 "page_lean": "[{\"ref\":\"P1\",\"guess\":\"owed\"},{\"ref\":\"P2\",\"guess\":\"date\"},{\"ref\":\"P3\",\"guess\":\"overdue\"},{\"ref\":\"P4\",\"guess\":\"overdue\"},{\"ref\":\"P5\",\"guess\":\"held\"},{\"ref\":\"P6\",\"guess\":\"drifting\"},{\"ref\":\"P7\",\"guess\":\"fine\"},{\"ref\":\"P8\",\"guess\":\"fine\"},{\"ref\":\"P9\",\"guess\":\"fine\"}]",
 "clip_note": ""
}

Reply:

{
 "task": "call-list",
 "verdict": "call",
 "title": "5 calls, starting with Maya",
 "summary": "Maya comes first since you still owe them the photos, then Grandpa Lou ahead of the Oct 26 birthday, overdue catch-ups with Dev and Aunt Rosa, and a drifting one with Priya.",
 "picks": [
  {
   "ref": "P1",
   "why": "owed",
   "why_line": "Your notes say you promised to send the lake trip photos.",
   "points": [
    "Mention you still owe them the lake trip photos.",
    "Ask how the new job is going."
   ],
   "opener": "Hi Maya, I'd like to catch up and send over those lake trip photos I promised. When suits you?"
  },
  {
   "ref": "P2",
   "why": "date",
   "why_line": "The notes name Oct 26, 5 days away.",
   "points": [
    "Ask if they have plans for the Oct 26 birthday.",
    "Ask what's new in the garden to share with them."
   ],
   "opener": "Hi Grandpa Lou, I'd like to catch up before your Oct 26 birthday. When suits you?"
  },
  {
   "ref": "P3",
   "why": "overdue",
   "why_line": "It has been 101 days and you meant to talk every 30.",
   "points": [
    "Ask how the move to Leeds has been.",
    "Ask whether they still run on Saturdays."
   ],
   "opener": "Hi Dev, I'd like to catch up and hear how the move to Leeds is going. When suits you?"
  },
  "... (5 picks in all)"
 ],
 "later": [],
 "held": [
  {
   "ref": "P5",
   "why_line": "The notes say to give space while things settle."
  }
 ]
}

eight people, room for three: three wait, one away until a later date - call

Input:

{
 "task": "call-list",
 "today": "2026-10-21",
 "count": "3",
 "people": "[{\"id\":\"P1\",\"name\":\"Hana\",\"last\":\"?\",\"days_since\":\"\",\"cadence_days\":30,\"notes\":\"met at the climbing gym; also into board games\",\"dates\":[]},{\"id\":\"P2\",\"name\":\"Marcus\",\"last\":\"2026-06-01\",\"days_since\":142,\"cadence_days\":30,\"notes\":\"old flatmate; wedding photos still not sent - I said I'd post them\",\"dates\":[]},{\"id\":\"P3\",\"name\":\"Lin\",\"last\":\"2 months ago\",\"days_since\":60,\"cadence_days\":42,\"notes\":\"started a podcast ab ... (1152 characters)",
 "page_lean": "[{\"ref\":\"P1\",\"guess\":\"overdue\"},{\"ref\":\"P2\",\"guess\":\"owed\"},{\"ref\":\"P3\",\"guess\":\"overdue\"},{\"ref\":\"P4\",\"guess\":\"overdue\"},{\"ref\":\"P5\",\"guess\":\"drifting\"},{\"ref\":\"P6\",\"guess\":\"date\"},{\"ref\":\"P7\",\"guess\":\"held\"},{\"ref\":\"P8\",\"guess\":\"fine\"}]",
 "clip_note": ""
}

Reply:

{
 "task": "call-list",
 "verdict": "call",
 "title": "Three calls, starting with Marcus",
 "summary": "Marcus comes first because you still owe the wedding photos you promised, then Tomas ahead of the move, and Hana who's overdue for a catch-up.",
 "picks": [
  {
   "ref": "P2",
   "why": "owed",
   "why_line": "Your notes say you still haven't sent the wedding photos you promised to post.",
   "points": [
    "Mention the wedding photos you still need to send over.",
    "Ask what has been keeping them busy."
   ],
   "opener": "Hi Marcus! I'd love to catch up - I still have your wedding photos to send over. When suits you?"
  },
  {
   "ref": "P6",
   "why": "date",
   "why_line": "The notes name Oct 24, 3 days away.",
   "points": [
    "Ask how the move is coming along.",
    "Ask if there's anything they need a hand with."
   ],
   "opener": "Hi Tomas! I'd love to catch up before your move. When suits you?"
  },
  {
   "ref": "P1",
   "why": "overdue",
   "why_line": "The list has no last date for them, and you meant to talk every 30 days.",
   "points": [
    "Ask how things are going at the climbing gym.",
    "Ask what board games they've been enjoying."
   ],
   "opener": "Hi Hana! I'd love to catch up - how's the climbing gym been? When suits you?"
  }
 ],
 "later": [
  {
   "ref": "P4",
   "why": "overdue"
  },
  {
   "ref": "P3",
   "why": "overdue"
  },
  {
   "ref": "P5",
   "why": "drifting"
  }
 ],
 "held": [
  {
   "ref": "P7",
   "why_line": "The notes say they are away on fieldwork until Nov 15."
  }
 ]
}

four people all talked to lately - all-lit

Input:

{
 "task": "call-list",
 "today": "2026-10-21",
 "count": "5",
 "people": "[{\"id\":\"P1\",\"name\":\"Ana\",\"last\":\"yesterday\",\"days_since\":1,\"cadence_days\":7,\"notes\":\"swapped soup recipes\",\"dates\":[]},{\"id\":\"P2\",\"name\":\"Ben\",\"last\":\"2026-10-15\",\"days_since\":6,\"cadence_days\":30,\"notes\":\"back from the conference, sounded tired\",\"dates\":[]},{\"id\":\"P3\",\"name\":\"Cleo\",\"last\":\"3 days ago\",\"days_since\":3,\"cadence_days\":14,\"notes\":\"her exam results come out Dec 4\",\"dates\":[{\"text\":\"Dec 4\",\"in_days\":44}]},{ ... (533 characters)",
 "page_lean": "[{\"ref\":\"P1\",\"guess\":\"fine\"},{\"ref\":\"P2\",\"guess\":\"fine\"},{\"ref\":\"P3\",\"guess\":\"fine\"},{\"ref\":\"P4\",\"guess\":\"fine\"}]",
 "clip_note": ""
}

Reply:

{
 "task": "call-list",
 "verdict": "all-lit",
 "title": "Everyone is lit today",
 "summary": "Nobody needs a call today - you're all caught up. Cleo is closest to due, 3 days into a 14-day rhythm.",
 "picks": [],
 "later": [],
 "held": []
}

Build the input yourself

Load the script in node (global.window = global; require("./litkit.js")), then:

const K = LitKit;
const form = K.form({ today: "2026-10-21", count: "3",
  list: "Kim | 2026-09-02 | monthly | I said I'd send the recipe\nAl | 6 weeks ago | 2w | exam on Oct 30" });
const people = K.read(form).list;                 // days_since, cadence_days, dates, the page's guess (lean)
const built = K.buildInput(form);
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.ics(reply, built, form)                      // a calendar file of call slots

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://keep-lit.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":"keep-lit"}' | 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": "call-list", "today": "2026-10-21", "count": "5", "people": "[{\"id\":\"P1\",\"name\":\"Maya Ortiz\",\"last\":\"2026-09-30\",\"days_since\":21,\"cadence_days\":21,\"notes\":\"I promised to send her the photos from the lake trip; started a new job in September\",\"dates\":[]},{\"id\":\"P2\",\"name\":\"Grandpa Lou\",\"last\":\"2026-10-04\",\"days_since\":17,\"cadence_days\":7,\"notes\":\"birthday Oct 26; likes hearing about the garden\",\"dates\":[{\"text\":\"Oct 26\",\"in_days\":5}]}, ...]", "page_lean": "[...]"}
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 keep-lit:<task>:<inputHash>:a<attempt> (the one task is call-list), 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="keep-lit:call-list:$(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: keep-lit:call-list:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json