Drive Still Going from your own code
The one metered task, report, is available over HTTP. It takes a life table the page has already computed - one line per item (arrived, went or still going, months), the counts, the median life, the share still going at 6, 12 and 24 months, a Weibull shape and the verdict - and returns a deadpan actuary's note: a headline, a plain reading of the curve, one life note per item, one practical change aimed at the commonest way they go, a word on the next one and a caveat.
The division of labour first: the page does the arithmetic; the model does the reading. The Kaplan-Meier curve (with items still going or given away in working order censored), the median, the Weibull shape, the verdict (too-few-to-tell, dies-young, wears-out, luck-of-the-draw, in that check order) and the leading cause with its tie-break are all computed by LifeKit.buildInput. After the reply, the page re-checks it against its own numbers.
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": "..." } }
The token is minted for this app (the guest call takes {"slug":"still-going"}), 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, items, verdict, median) 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: the note is metered and needs a personal sign-in. |
| 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 input and the rules
Every run sends task: report (the one task is report); a missing or different one is still answered as report. All fields are strings.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always report. |
kind | yes | What the items are, in the person's words (up to 40 characters). |
as_of | yes | The month the page counted up to, as Oct 2026. |
items | yes | One numbered line per item: 1. black folding | arrived Oct 2023 | went Nov 2023 (lost) | lasted 1 month | shorter than the median; still-going and given-away items say so instead of went. |
counts | yes | How many ended, by cause, and how many have not ended. |
median | yes | The Kaplan-Meier median life, or not reached. |
still_going_at | yes | The share still going at 6, 12 and 24 months, or beyond the longest record. |
shape | yes | The censored Weibull shape to two decimals, or none. |
verdict | yes | The page's verdict id; the reply copies it. |
leading_cause | yes | lost, broke, wore out, borrowed, never back or none; ties go to the first in that order. |
retry_note | no | Only on a reformat retry: why the previous reply was unusable. |
Verdicts, in check order: fewer than 4 endings is too-few-to-tell; a shape under 0.8 is dies-young; over 1.25 is wears-out; otherwise luck-of-the-draw. An item that arrived and went in the same month counts as half a month.
The reply
One JSON object, no prose around it:
{"task": "report", "verdict": "luck-of-the-draw", "headline": "...", "reading": "...", "lives": [{"item": "black folding", "note": "..."}], "advice": "...", "next_one": "...", "caveat": "..."}
| Field | What it holds |
|---|---|
verdict | The input's verdict, copied. |
headline | One sentence: the kind, the verdict in plain words and the median. |
reading | One or two sentences on the curve, using only the page's figures. |
lives[] | One {item, note} per item, in input order; item is the exact name. |
advice | One practical change aimed at the leading cause. |
next_one / caveat | What follows for the next item, and how sure this is. |
The page reads the reply with ReplyKit.normalize and checks it with ReplyKit.check, which lists, under "The page's check", a verdict that differs from the page's, items with no life note or notes for unknown items, a still-going item described as ended, and numbers or weekdays the page never sent.
Worked examples - every sample
These are the free samples on the page: the exact input the page sends and the reply that replays for free. Together they cover all four verdicts - dies young, luck of the draw, wears out and too few to tell.
Phone chargers - Dies young
Input:
{
"task": "report",
"kind": "phone chargers",
"as_of": "Oct 2026",
"items": "1. white braided | arrived Feb 2023 | went Mar 2023 (broke) | lasted 1 month | about the median\n2. short one | arrived Apr 2023 | went Apr 2023 (broke) | lasted under a month | shorter than the median\n3. the long one | arrived May 2023 | went Aug 2025 (wore out) | lasted 27 months | longer than the median\n4. bedside | arrived Sep 2023 | went Nov 2023 (broke) | lasted 2 months | about the median\n5. work bag | arrived Jan 2024 | still going | 33 months so far | already past the median\n6. blue | arrived Jun 2024 | went Jul 2024 (broke) | lasted 1 month | about the median\n7. travel one | arrived Aug 2024 | went Jan 2026 (lost) | lasted 17 months | longer than the median\n8. kitchen | arrived Feb 2025 | still going | 20 months so far | already past the median",
"counts": "8 items: 6 ended (1 lost, 4 broke, 1 wore out); 2 not ended (2 still going)",
"median": "2 months",
"still_going_at": "6 months: 50%; 12 months: 50%; 24 months: 38%",
"shape": "0.58",
"verdict": "dies-young",
"leading_cause": "broke"
}
Reply:
{
"task": "report",
"verdict": "dies-young",
"headline": "For phone chargers, the early months are the dangerous stretch: one that gets past them tends to keep going, and the median life here is 2 months.",
"reading": "Half of these phone chargers were done within 2 months, yet the curve flattens out from there: 50% were still going at 6 months and at 12 months, and 38% at 24 months. Of the 8 chargers, 6 have ended (1 lost, 4 broke, 1 wore out), which fits a kind of thing where the early days carry the real risk and a survivor tends to settle in for the long run.",
"lives": [
{
"item": "white braided",
"note": "It broke after 1 month, about the median."
},
{
"item": "short one",
"note": "It broke in under a month, shorter than the median."
},
{
"item": "the long one",
"note": "It wore out after 27 months, longer than the median."
},
{
"item": "bedside",
"note": "It broke after 2 months, about the median."
},
{
"item": "work bag",
"note": "It is still going at 33 months so far, already past the median."
},
{
"item": "blue",
"note": "It broke after 1 month, about the median."
},
{
"item": "travel one",
"note": "It was lost after 17 months, longer than the median."
},
{
"item": "kitchen",
"note": "It is still going at 20 months so far, already past the median."
}
],
"advice": "Try coiling the cable loosely and unplugging it by the plug rather than the cord, so you spare the next one the strain that tends to break this kind of thing.",
"next_one": "Give the next charger a thorough check in its first few months, since any fault in this kind of thing tends to show itself early.",
"caveat": "With only 8 chargers and 6 endings, this is an estimate rather than a hard rule."
}
Umbrellas - Luck of the draw
Input:
{
"task": "report",
"kind": "umbrellas",
"as_of": "Oct 2026",
"items": "1. black folding | arrived Oct 2023 | went Nov 2023 (lost) | lasted 1 month | shorter than the median\n2. golf size | arrived Jan 2024 | went Mar 2024 (broke) | lasted 2 months | shorter than the median\n3. the spotty one | arrived Feb 2024 | went Nov 2024 (lost) | lasted 9 months | about the median\n4. clear dome | arrived Jul 2024 | went Sep 2025 (lost) | lasted 14 months | longer than the median\n5. spare at work | arrived Dec 2024 | went Mar 2025 (borrowed, never back) | lasted 3 months | shorter than the median\n6. red | arrived Jan 2025 | still going | 21 months so far | already past the median\n7. green | arrived May 2025 | went Apr 2026 (lost) | lasted 11 months | longer than the median\n8. tiny one | arrived Apr 2026 | still going | 6 months so far | not yet past the median",
"counts": "8 items: 6 ended (4 lost, 1 broke, 1 borrowed, never back); 2 not ended (2 still going)",
"median": "9 months",
"still_going_at": "6 months: 63%; 12 months: 31%; 24 months: beyond the longest record",
"shape": "1.06",
"verdict": "luck-of-the-draw",
"leading_cause": "lost"
}
Reply:
{
"task": "report",
"verdict": "luck-of-the-draw",
"headline": "For umbrellas, the endings happen at a steady rate no matter the age, with a median life of 9 months.",
"reading": "The median life is 9 months, with 63% of umbrellas still going at 6 months and 31% at 12 months. Of the 6 that have ended (4 lost, 1 broke, 1 borrowed, never back), age makes no difference to the odds, so an umbrella that's been around a while is no likelier to go than a new one.",
"lives": [
{
"item": "black folding",
"note": "The black folding umbrella lasted 1 month before it was lost, shorter than the median."
},
{
"item": "golf size",
"note": "The golf size lasted 2 months before it broke, shorter than the median."
},
{
"item": "the spotty one",
"note": "The spotty one lasted 9 months before it was lost, about the median."
},
{
"item": "clear dome",
"note": "The clear dome lasted 14 months before it was lost, longer than the median."
},
{
"item": "spare at work",
"note": "The spare at work lasted 3 months before it was borrowed and never came back, shorter than the median."
},
{
"item": "red",
"note": "The red umbrella has been going 21 months so far, already past the median."
},
{
"item": "green",
"note": "The green umbrella lasted 11 months before it was lost, longer than the median."
},
{
"item": "tiny one",
"note": "The tiny one has been going 6 months so far, not yet past the median."
}
],
"advice": "Since going missing is how most of your umbrellas have ended, try keeping it clipped to the same bag each time so you can keep track of it more easily.",
"next_one": "For the next umbrella, there's no reason to retire it early for its age, since an older one isn't at any greater risk than a newer one.",
"caveat": "With only 8 umbrellas and 6 endings counted, this is an estimate rather than a hard rule."
}
Phone cases - Wears out
Input:
{
"task": "report",
"kind": "phone cases",
"as_of": "Oct 2026",
"items": "1. clear | arrived Mar 2022 | went Feb 2023 (wore out) | lasted 11 months | about the median\n2. black rubber | arrived Feb 2023 | went Mar 2024 (wore out) | lasted 13 months | about the median\n3. leather flip | arrived Mar 2024 | went Mar 2025 (wore out) | lasted 12 months | about the median\n4. glitter | arrived Sep 2024 | went Aug 2025 (broke) | lasted 11 months | about the median\n5. plain grey | arrived Mar 2025 | went May 2026 (wore out) | lasted 14 months | longer than the median\n6. wallet one | arrived Aug 2025 | given away Jan 2026, still working | 5 months in use | not yet past the median\n7. green silicone | arrived May 2026 | still going | 5 months so far | not yet past the median",
"counts": "7 items: 5 ended (1 broke, 4 wore out); 2 not ended (1 still going, 1 given away, still working)",
"median": "12 months",
"still_going_at": "6 months: 100%; 12 months: 40%; 24 months: beyond the longest record",
"shape": "11.19",
"verdict": "wears-out",
"leading_cause": "wore out"
}
Reply:
{
"task": "report",
"verdict": "wears-out",
"headline": "Phone cases here tend to wear out more as they age, with a typical one lasting 12 months.",
"reading": "At 6 months 100% of phone cases were still going, at 12 months - the median - that had dropped to 40%, and by 24 months the figure is beyond the longest record, in keeping with endings growing more likely the longer a case stays in service. Of the 7 items, 5 have ended (1 broke, 4 wore out), while 2 have not (1 still going, 1 given away, still working).",
"lives": [
{
"item": "clear",
"note": "It wore out after 11 months, about the median."
},
{
"item": "black rubber",
"note": "It wore out after 13 months, about the median."
},
{
"item": "leather flip",
"note": "It wore out after 12 months, about the median."
},
{
"item": "glitter",
"note": "It broke after 11 months, about the median."
},
{
"item": "plain grey",
"note": "It wore out after 14 months, longer than the median."
},
{
"item": "wallet one",
"note": "It was given away after 5 months, still working, and not yet past the median."
},
{
"item": "green silicone",
"note": "It is still going at 5 months, not yet past the median."
}
],
"advice": "You could extend the next one's life by keeping it free of grit and pocket debris, and planning to swap it out on your own schedule before it wears through.",
"next_one": "For your next phone case, expect to replace it around 12 months in and plan for that.",
"caveat": "With only 7 phone cases counted and 5 endings so far, this reading is a rough estimate rather than a hard rule."
}
Mugs - Too few to tell
Input:
{
"task": "report",
"kind": "mugs",
"as_of": "Oct 2026",
"items": "1. the big blue one | arrived Jun 2023 | went Feb 2024 (broke) | lasted 8 months | shorter than the median\n2. souvenir mug | arrived Jun 2023 | still going | 40 months so far | already past the median\n3. plain white | arrived Nov 2023 | given away Nov 2024, still working | 12 months in use | not yet past the median\n4. chipped one | arrived Jan 2024 | went Jul 2026 (broke) | lasted 30 months | about the median\n5. striped | arrived Jul 2025 | still going | 15 months so far | not yet past the median",
"counts": "5 items: 2 ended (2 broke); 3 not ended (2 still going, 1 given away, still working)",
"median": "30 months",
"still_going_at": "6 months: 100%; 12 months: 80%; 24 months: 80%",
"shape": "1.61",
"verdict": "too-few-to-tell",
"leading_cause": "broke"
}
Reply:
{
"task": "report",
"verdict": "too-few-to-tell",
"headline": "For mugs, there have been too few endings yet to say how the risk changes with age, with a median life of 30 months so far.",
"reading": "Of 5 mugs, 2 have ended so far (2 broke), which isn't enough to read how the pattern runs with age. At 6 months 100% were still going, 80% by 12 months, and still 80% through 24 months, with a median of 30 months reached among this group so far.",
"lives": [
{
"item": "the big blue one",
"note": "The big blue one lasted 8 months before it broke, shorter than the median."
},
{
"item": "souvenir mug",
"note": "The souvenir mug is still going at 40 months so far, already past the median."
},
{
"item": "plain white",
"note": "Plain white was given away still working after 12 months in use, not yet past the median."
},
{
"item": "chipped one",
"note": "The chipped one lasted 30 months before it broke, about the median."
},
{
"item": "striped",
"note": "Striped is still going at 15 months so far, not yet past the median."
}
],
"advice": "You might try keeping your mugs stored with a little space between them, rather than stacked tight, to spare them the bumps that tend to chip or crack a mug.",
"next_one": "Keep logging each new mug's arrival and departure, and look at this again once more mugs have ended.",
"caveat": "With just 5 mugs tracked and only 2 having ended, this is a small sample rather than a firm rule."
}
Build the input yourself
Load the engine in node (global.window = global; require("./lifekit.js"); require("./replykit.js")), then:
const K = LifeKit, R = ReplyKit;
const built = K.buildInput(K.form({ kind: "umbrellas", as_of: "2026-10", rows: [
{ name: "black folding", arrived: "2023-10", went: "2023-11", how: "lost" },
{ name: "red", arrived: "2025-01", went: "", how: "going" },
{ name: "golf size", arrived: "2024-01", went: "2024-03", how: "broke" } ] })); // built.ok is false, with built.note, until the rows are complete
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output));
// R.check(reply, built).notes // the page's re-check of the note
// built.plan.steps, built.plan.median, built.plan.shape // the curve the page drew
Row keys: name (up to 40 characters), arrived and went (months such as 2024-03 or Mar 2024), how - one of going, lost, broke, wore, borrowed, given. Up to 12 rows.
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://still-going.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://still-going.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"]
// still-going.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://still-going.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://still-going.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
}
// StillGoing.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 StillGoing {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://still-going.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://still-going.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://still-going.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://still-going.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":"still-going"}' | 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": "still-going"})["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: "still-going" })).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": "still-going"}, 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", "still-going"), 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: "still-going" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "still-going"])["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 = "still-going" }))!["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": "report", "kind": "umbrellas", "as_of": "Oct 2026", "items": "1. ...", "counts": "...", "median": "9 months", "still_going_at": "...", "shape": "1.06", "verdict": "luck-of-the-draw", "leading_cause": "lost"}
# - see the field table
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": "report", "kind": "umbrellas", "as_of": "Oct 2026", "items": "1. ...", "counts": "...", "median": "9 months", "still_going_at": "...", "shape": "1.06", "verdict": "luck-of-the-draw", "leading_cause": "lost"}
# - see the field table
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: "report", kind, as_of, items, counts, median, still_going_at, shape, verdict, leading_cause} (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 still-going:<task>:<inputHash>:a<attempt> (the one task is report), 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="still-going:report:$(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 = "still-going:" + 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"), reply.get("headline"))
import { createHash } from "node:crypto";
const key = `still-going:${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, reply.headline);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "still-going:" + 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 = "still-going:" + 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 = "still-going:#{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["verdict"], reply["headline"]].inspect
$key = "still-going:" . $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["verdict"] ?? ""), " ", ($reply["headline"] ?? ""), "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"still-going:{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: still-going:report:$(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": "report", "kind": "umbrellas", "as_of": "Oct 2026", "items": "1. ...", "counts": "...", "median": "9 months", "still_going_at": "...", "shape": "1.06", "verdict": "luck-of-the-draw", "leading_cause": "lost"}
# - see the field table
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "still-going:report:" + 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": `still-going:report:${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", "still-going:report:"+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", "still-going:report:" + 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" => "still-going:report:#{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: still-going:report:" . 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", $"still-going:report:{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);