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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field (task, today, people) 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: picking today's calls 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, 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:
| Order | Reason | When |
|---|---|---|
| 1 | held | The 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. |
| 2 | owed | The notes say you owe them something: a call or reply back, or a thing you promised. |
| 3 | date | A date in the notes is from 2 days ago to 14 days ahead (in_days -2 to 14). |
| 4 | overdue | days_since is empty, or at least cadence_days. |
| 5 | drifting | days_since is at least three quarters of cadence_days. |
| 6 | fine | Otherwise - 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
| Field | Type | Notes |
|---|---|---|
task | string | call-list. Required. |
today | string | The as-of day, YYYY-MM-DD. Required. |
count | string | 3, 5 or 7 - how many calls there is room for. |
people | string (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_lean | string (JSON) | [{ref, guess}] - the page's own guess from word lists, which the model judges for itself. |
clip_note, retry_note | string | What 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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://keep-lit.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"]
// keep-lit.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://keep-lit.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://keep-lit.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
}
// KeepLit.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 KeepLit {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://keep-lit.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://keep-lit.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://keep-lit.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://keep-lit.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":"keep-lit"}' | 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": "keep-lit"})["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: "keep-lit" })).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": "keep-lit"}, 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", "keep-lit"), 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: "keep-lit" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "keep-lit"])["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 = "keep-lit" }))!["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": "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}'
inp = json.load(open("input.json")) # {"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": "[...]"}
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: "call-list", today, count, people, 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 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}'
import hashlib, time
key = "keep-lit:" + 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 reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `keep-lit:${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 := "keep-lit:" + 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 = "keep-lit:" + 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 = "keep-lit:#{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 = "keep-lit:" . $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 = $"keep-lit:{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: keep-lit:call-list:$(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": "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": "[...]"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "keep-lit:call-list:" + 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": `keep-lit:call-list:${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", "keep-lit:call-list:"+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", "keep-lit:call-list:" + 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" => "keep-lit:call-list:#{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: keep-lit:call-list:" . 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", $"keep-lit:call-list:{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);