← Spare Keys
API
Tokens

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.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field such as task, keeper or span is missing.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal token.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field

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:

OrderVerdictWhen
1thinThe 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.
2blanksOtherwise, 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).
3cutOtherwise: 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

FieldTypeNotes
taskstringring or letter. Required.
keeperstringWho 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.
spanstringdays, weeks or for_good - how long you might be out of reach. Required.
notes, categories, redactionsstring (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, keysstring, string (JSON)letter only: plain or warm, and the finished keys [{id, item, when, where, contact, first_move, missing}].
clip_note, retry_notestringWhat 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"}
}

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')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}' 

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "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}' 

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}' 

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