Drive Spare Keys from your own code
Both metered lanes are available over HTTP. ring takes rough handover notes - one thing per line, with secrets already removed - plus who would open them and how long you might be out of reach, and returns a verdict - blanks, cut or thin - with one key per line: a short name, a category, a row (first day or two, first week, first month, if it runs long), where things are, who to reach, a first move, what is missing and a question to fill it. letter takes those keys and returns a plain letter to that person, in the order they will need it, with every blank listed as an open question. A natural use is a household or small-business binder that you re-run every few months.
The division of labour first: the page owns the secret scrubber and the cabinet; the model sorts and writes; the page re-checks the reply. The scrubber, the line splitter, the page's category guess and clipping come from keykit.js, which runs in node as well as in the browser. Run KeyKit.scrubLine (or KeyKit.buildInput, which calls it) on anything you send: the model is told never to repeat a secret, but the safest secret is one it never sees. KeyKit.check holds each reply to the notes: every line became exactly one key or was set aside, every place and contact shares words with its own line, the verdict follows its rules, every key appears in the letter once and on its own row, every blank is listed, and no removed secret, stray number or internal label turns up in prose.
Spare Keys sorts what you type; it is not legal, financial or medical advice. 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":"spare-keys"}), 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 such as task, keeper or span 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: both lanes are metered and need 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
Every run sends task: ring or letter. One system prompt routes on it; a missing or unknown task is treated as ring. A ring verdict is decided by three ordered, non-overlapping rules - the first that applies wins:
| Order | Verdict | When |
|---|---|---|
| 1 | thin | The notes name fewer than three concrete things to handle (a named bill, account, document, animal, plant, device, vehicle, home task or person to tell). No keys; 2-3 questions to answer first. |
| 2 | blanks | Otherwise, at least one key lists something missing: where (it needs papers, a device, a key, a login or a secret and the notes do not say where) or contact (it needs someone outside the household and the notes give no way to reach them). |
| 3 | cut | Otherwise: every key is complete. |
A letter reply has no verdict: every key appears in exactly one step, under the row it hangs on, and every blank key gets exactly one open question.
The declared input schema carries scalars only, so array-valued fields travel JSON-encoded as strings (KeyKit.toWire does this). Every value is a string.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | ring or letter. Required. |
keeper | string | Who would open it, in your own words ("my sister Ana"); the page sends "the person I trust" when it is left empty. Up to 80 characters. Required. |
span | string | days, weeks or for_good - how long you might be out of reach. Required. |
notes, categories, redactions | string (JSON) | ring only: the lines [{id, text, page_cat}] (ids L1.., up to 30 lines of up to 320 characters, scrubbed), the eight categories [{id, name}] (care, health, home, bills, money, papers, digital, people), and the secrets the page removed [{line, kind}]. |
voice, keys | string, string (JSON) | letter only: plain or warm, and the finished keys [{id, item, when, where, contact, first_move, missing}]. |
clip_note, retry_note | string | What the page cut, when it cut something; and only on a reformat retry, what was wrong. |
The replies
The job's output.output is a string holding one JSON object. For ring: task, verdict, title, summary, keys ([{id, line, item, category, when, where, contact, first_move, missing, ask}]; ids K1..; when is day1, week1, month1 or later; missing holds where and/or contact), skipped (line ids that name nothing to handle), unmentioned (up to three category ids) and questions. For letter: task, title, opening, sections ([{when, steps: [{keys, text}]}]), open_blanks ([{key, text}]) and closing. Parse with KeyKit.parseReply, then normalize(reply, task), then check(reply, form, ring), which returns the page's notes.
Worked examples
Replies produced against the app's prompt for the five sample runs (made-up notes; long fields and lists shortened; the full replies ship in example-result.js). Together they cover every verdict and both lanes.
ring - a renter going into hospital, with one password removed by the page: blanks
Input:
{
"task": "ring",
"keeper": "my sister Ana",
"span": "weeks",
"notes": "[{\"id\":\"L1\",\"text\":\"Rent is 1,450 a month, paid by standing order from the joint account on the 1st. Landlord: K. Haddad, 07700 900123\",\"page_cat\":\"bills\"},{\"id\":\"L2\",\"text\":\"Ollie the cat: food is in the hall cupboard, half a pouch morning and night\",\"page_ca ... (779 characters)",
"categories": "[{\"id\":\"care\",\"name\":\"Care (people and animals)\"},{\"id\":\"health\",\"name\":\"Health\"},{\"id\":\"home\",\"name\":\"Home and car\"},{\" ... (308 characters)",
"redactions": "[{\"line\":\"L6\",\"kind\":\"password\"}]"
}
Reply:
{
"task": "ring",
"verdict": "blanks",
"title": "Rent, Ollie the cat, bills and insurance",
"summary": "This cabinet holds the rent, Ollie the cat, the electric and gas bills, the car insurance, telling Priya at work, the laptop, the spare house key and the balcony plants. Most keys are ready, but a few are still missing where things are kept or who to contact.",
"keys": [
{
"id": "K1",
"line": "L1",
"item": "Rent payment",
"category": "bills",
"when": "week1",
"where": "The joint account",
"contact": "The landlord, K. Haddad, on 07700 900123",
"first_move": "Check that the standing order from the joint account still pays the rent on the 1st; call the landlord, K. Haddad, if it doesn't.",
"missing": [],
"ask": ""
},
{
"id": "K2",
"line": "L2",
"item": "Feeding the cat",
"category": "care",
"when": "day1",
"where": "The hall cupboard",
"contact": "",
"first_move": "Give Ollie half a pouch of food from the hall cupboard, morning and night.",
"missing": [],
"ask": ""
},
{
"id": "K3",
"line": "L3",
"item": "Electric and gas bills",
"category": "bills",
"when": "week1",
"where": "",
"contact": "",
"first_move": "Check that the direct debits for the electric and gas bills still go out.",
"missing": [
"where",
"contact"
],
"ask": "Which account are the electric and gas direct debits paid from, and who is the supplier to contact?"
},
"... (8 in all)"
],
"skipped": [],
"unmentioned": [
"health",
"money",
"papers"
],
"questions": []
}
ring - a solo developer's business, for good: cut
Input:
{
"task": "ring",
"keeper": "Sam",
"span": "for_good",
"notes": "[{\"id\":\"L1\",\"text\":\"Domain names renew every March with the registrar. The login is in the shared password vault under \\\"Domains\\\" (Sam has emergency access to the vault); the registrar's support is reachable from that login\",\"page_cat\":\"digital\"},{\"id\":\"L2\",\" ... (1302 characters)",
"categories": "[{\"id\":\"care\",\"name\":\"Care (people and animals)\"},{\"id\":\"health\",\"name\":\"Health\"},{\"id\":\"home\",\"name\":\"Home and car\"},{\" ... (308 characters)",
"redactions": "[]"
}
Reply:
{
"task": "ring",
"verdict": "cut",
"title": "Business logins, accounts and contacts",
"summary": "This cabinet holds the business's domains, hosting, bank account, customer invoices, support inbox and the open-source library. Every key already says where to find what is needed and who to contact, so all of them are cut.",
"keys": [
{
"id": "K1",
"line": "L1",
"item": "Domain names",
"category": "digital",
"when": "month1",
"where": "the shared password vault, under 'Domains' (Sam already has emergency access to it)",
"contact": "the registrar's support, reachable from that login",
"first_move": "Use the vault login under 'Domains' to renew before March.",
"missing": [],
"ask": ""
},
{
"id": "K2",
"line": "L2",
"item": "Web server hosting",
"category": "digital",
"when": "week1",
"where": "billed monthly to the business card; login in the vault under 'Hosting'; database export steps in the README in the ops folder on the shared drive",
"contact": "the hosting dashboard's support chat",
"first_move": "Export the database with the ops folder README before closing the hosting.",
"missing": [],
"ask": ""
},
{
"id": "K3",
"line": "L3",
"item": "Three yearly customers",
"category": "people",
"when": "week1",
"where": "the 'clients' spreadsheet on the shared drive",
"contact": "email each of them from the support inbox",
"first_move": "Email the three customers from the support inbox using the clients spreadsheet.",
"missing": [],
"ask": ""
},
"... (6 in all)"
],
"skipped": [
"L7"
],
"unmentioned": [
"care",
"health",
"home"
],
"questions": []
}
ring - "everything is in the usual places": thin
Input:
{
"task": "ring",
"keeper": "the person I trust",
"span": "days",
"notes": "[{\"id\":\"L1\",\"text\":\"Everything is in the usual places\",\"page_cat\":\"home\"},{\"id\":\"L2\",\"text\":\"Sort out the money stuff\",\"page_cat\":\"home\"},{\"id\":\"L3\",\"text\":\"You know what to do\",\"page_cat\":\"home\"}]",
"categories": "[{\"id\":\"care\",\"name\":\"Care (people and animals)\"},{\"id\":\"health\",\"name\":\"Health\"},{\"id\":\"home\",\"name\":\"Home and car\"},{\" ... (308 characters)",
"redactions": "[]"
}
Reply:
{
"task": "ring",
"verdict": "thin",
"title": "The usual places and money stuff",
"summary": "Your notes mention the usual places and sorting out the money stuff, but don't name anything specific to hand over. There's nothing solid enough yet to cut into keys, so a few questions would help first.",
"keys": [],
"skipped": [],
"unmentioned": [],
"questions": [
"What are the specific things - bills, accounts, animals, or tasks - that need handling?",
"Where would someone find the papers, keys, or logins needed for each one?",
"Is there anyone who should be told, and how can they be reached?"
]
}
letter - the renter's keys, plain voice
Input:
{
"task": "letter",
"keeper": "my sister Ana",
"span": "weeks",
"voice": "plain",
"keys": "[{\"id\":\"K1\",\"item\":\"Rent payment\",\"when\":\"week1\",\"where\":\"The joint account\",\"contact\":\"The landlord, K. Haddad, on 07700 900123\",\"first_move\":\"Check that the standing order from the joint account still pays the rent on the 1st; call the landlord, K. Haddad, if it doesn't.\",\"missing\":[]},{\"id\":\"K2\", ... (1653 characters)"
}
Reply:
{
"task": "letter",
"title": "The rent, the cat, and more",
"opening": "Ana, this is what needs looking after while I'm away, with where to look and who to call.",
"sections": [
{
"when": "day1",
"steps": [
{
"keys": [
"K2"
],
"text": "Give Ollie half a pouch of food from the hall cupboard, morning and night."
},
{
"keys": [
"K5"
],
"text": "Tell Priya on the support team; she has the calendar."
},
{
"keys": [
"K8"
],
"text": "Water the balcony plants every few days."
}
]
},
{
"when": "week1",
"steps": [
{
"keys": [
"K1"
],
"text": "Check that the standing order from the joint account still pays the rent on the 1st; call the landlord, K. Haddad, on 07700 900123, if it doesn't."
},
{
"keys": [
"K3"
],
"text": "Check that the direct debits for the electric and gas bills still go out."
},
{
"keys": [
"K6"
],
"text": "Find out where the laptop password is kept before trying to use it."
},
{
"keys": [
"K7"
],
"text": "Get the spare key from Tomas, the downstairs neighbour, if it's needed."
}
]
},
"... (3 in all)"
],
"open_blanks": [
{
"key": "K3",
"text": "I still need to add where to find everything for the electric and gas bills, and who to contact about them."
},
{
"key": "K4",
"text": "I still need to add where the policy papers for the car insurance are kept, and who to contact about them."
},
"... (3 in all)"
],
"closing": "That's everything I can think of for now, Ana."
}
letter - the developer's keys, warm voice
Input:
{
"task": "letter",
"keeper": "Sam",
"span": "for_good",
"voice": "warm",
"keys": "[{\"id\":\"K1\",\"item\":\"Domain names\",\"when\":\"month1\",\"where\":\"the shared password vault, under 'Domains' (Sam already has emergency access to it)\",\"contact\":\"the registrar's support, reachable from that login\",\"first_move\":\"Use the vault login under 'Domains' to renew before March.\",\"missing\":[]},{\"id\" ... (1633 characters)"
}
Reply:
{
"task": "letter",
"title": "Domains, hosting, customers, bank account and library",
"opening": "Dear Sam, thank you for taking this on. What follows covers the domains, the hosting, the three customers, the bank account, the support inbox and the open-source library, and you can take as much time as you need working through it.",
"sections": [
{
"when": "week1",
"steps": [
{
"keys": [
"K2"
],
"text": "The hosting is billed monthly to the business card, and the login is in the vault under 'Hosting'. Please export the database using the README in the ops folder on the shared drive before closing the hosting, and use the hosting dashboard's support chat if you need help."
},
{
"keys": [
"K3"
],
"text": "The three yearly customers are listed in the 'clients' spreadsheet on the shared drive; please email each of them from the support inbox."
},
{
"keys": [
"K5"
],
"text": "The support inbox login is in the vault under 'Mail'; please use it to set an auto-reply on the inbox."
}
]
},
{
"when": "month1",
"steps": [
{
"keys": [
"K1"
],
"text": "The domain names can be renewed using the vault login under 'Domains', which you already have emergency access to. The registrar's support is reachable from that same login, and this should be done before March."
},
{
"keys": [
"K4"
],
"text": "The statements for the business bank account are in the blue binder in the office desk drawer. Please contact the accountant, Dana Quorrel, at dana@quorrel-books.example, to ask about it."
},
{
"keys": [
"K6"
],
"text": "Please ask Lee Vantrice, at lee@vantricecode.example, to take over as maintainer of the open-source library."
}
]
}
],
"open_blanks": [],
"closing": "Thank you again for taking care of all this, Sam."
}
Build the input yourself
Load the script in node (global.window = global; require("./keykit.js")), then:
const K = KeyKit;
const form = K.form({ keeper: "my brother Sam", span: "weeks",
notes: "The dog eats twice a day - food under the sink\nRent: standing order on the 1st; landlord's number is on the fridge" });
console.log(K.flags(form), K.lean(form), K.items(form).items); // the scrubber's flags, the page's lean, the lines
const wire = K.toWire(K.buildInput("ring", form).input); // POST this to /estimate and /run
// then: const ring = K.normalize(K.parseReply(job.output.output), "ring");
// const { notes } = K.check(ring, form); // the page's re-check of the reply
// for the letter: the same form plus the keys
// const letterWire = K.toWire(K.buildInput("letter", Object.assign(form, { voice: "warm" }), ring).input);
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://spare-keys.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://spare-keys.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"]
// spare-keys.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://spare-keys.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://spare-keys.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
}
// SpareKeys.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 SpareKeys {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://spare-keys.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://spare-keys.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://spare-keys.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://spare-keys.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":"spare-keys"}' | 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": "spare-keys"})["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: "spare-keys" })).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": "spare-keys"}, 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", "spare-keys"), 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: "spare-keys" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "spare-keys"])["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 = "spare-keys" }))!["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": "ring", "keeper": "my sister Ana", "span": "weeks", "notes": "[{\"id\":\"L1\",\"text\":\"Rent is 1,450 a month, paid by standing order from the joint account on the 1st. Landlord: K. Haddad, 07700 900123\",\"page_cat\":\"bills\"},{\"id\":\"L2\",\"text\":\"Ollie the cat: food is in the hall cupboard, half a pouch morning and night\",\"page_cat\":\"care\"}, ...]", "categories": "[{\"id\":\"care\",\"name\":\"Care (people and animals)\"}, ...]", "redactions": "[{\"line\":\"L6\",\"kind\":\"password\"}]"}
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": "ring", "keeper": "my sister Ana", "span": "weeks", "notes": "[{\"id\":\"L1\",\"text\":\"Rent is 1,450 a month, paid by standing order from the joint account on the 1st. Landlord: K. Haddad, 07700 900123\",\"page_cat\":\"bills\"},{\"id\":\"L2\",\"text\":\"Ollie the cat: food is in the hall cupboard, half a pouch morning and night\",\"page_cat\":\"care\"}, ...]", "categories": "[{\"id\":\"care\",\"name\":\"Care (people and animals)\"}, ...]", "redactions": "[{\"line\":\"L6\",\"kind\":\"password\"}]"}
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: "ring", keeper, span, notes, categories, redactions, ... (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 spare-keys:<task>:<inputHash>:a<attempt> (the lanes are ring and letter), 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="spare-keys:ring:$(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 = "spare-keys:" + 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 read as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `spare-keys:${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 := "spare-keys:" + 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 = "spare-keys:" + 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 = "spare-keys:#{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 = "spare-keys:" . $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 = $"spare-keys:{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: spare-keys:ring:$(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": "ring", "keeper": "my sister Ana", "span": "weeks", "notes": "[{\"id\":\"L1\",\"text\":\"Rent is 1,450 a month, paid by standing order from the joint account on the 1st. Landlord: K. Haddad, 07700 900123\",\"page_cat\":\"bills\"},{\"id\":\"L2\",\"text\":\"Ollie the cat: food is in the hall cupboard, half a pouch morning and night\",\"page_cat\":\"care\"}, ...]", "categories": "[{\"id\":\"care\",\"name\":\"Care (people and animals)\"}, ...]", "redactions": "[{\"line\":\"L6\",\"kind\":\"password\"}]"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "spare-keys:ring:" + 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": `spare-keys:ring:${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", "spare-keys:ring:"+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", "spare-keys:ring:" + 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" => "spare-keys:ring:#{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: spare-keys:ring:" . 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", $"spare-keys:ring:{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);