← Twelve Each
API
Tokens

Drive Twelve Each from your own code

The one metered step - the ruling on a 12-12-12 declutter board - is available over HTTP. A natural use is a home-organizing app, a moving checklist or a chat assistant that already holds a list of what is in a room: your code sends the board, and gets back a bin and a one-sentence reason for every card the person has not placed themselves, plus second looks at any irreplaceable thing they were about to throw out.

The split first: the page reads the lines; the model rules; the page checks it. The facts on each line (quantities, last used, broken, whose it is, where it belongs, sentimental, a record, a safety item), the page's suggested bin and the counts come from binkit.js, which runs in node as well as in the browser. Its plan function then checks the reply before anything moves: every asked card must be ruled, a sentimental item or a record is never tossed or donated (the page sends it to the hold box instead), a safety item is never removed unless its line says it is broken or expired, a second look may only move the person's own placements toward keep or the hold box, and every cited id must be on the board.

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":"twelve-each"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare: never wrap it as {"input": {…}}.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or task 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: the ruling is metered and needs a personal token.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Every run sends task. There is one task, rule. It returns title, summary; rulings [{id, bin, why, guard, rehome_to, hold_days, cite}]; second_looks [{id, to, why, cite}]; first_fifteen (three to five steps); dont_buy (one sentence); caveats.

binMeans
tossRubbish, recycling or e-waste: broken, worn out, expired, dried out, a single glove.
donateUsable by someone else: never used, outgrown, does not fit, surplus duplicates, unused for a year or more.
rehomePut back where it lives, or give back to its owner; rehome_to names the room or the person.
keepIn use, load-bearing, or a safety item.
holdThe hold box - a staged removal: box it, date it, and let it go if nobody missed it within hold_days (14 to 90).

guard is sentimental, record, safety, no-evidence or empty. Cites are the board's ids: cards I1…I60 and N-goal. A second look's to is only ever hold or keep.

The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (BinKit.toWire does this). Only task is required by the schema.

Input fields

FieldTypeNotes
taskstringAlways rule.
zonestringThe zone's name (60 characters).
todaystringYYYY-MM-DD; hold-box dates count from it.
itemsstring (JSON)[{id, text, qty, facts[], bin, by, page_why?, where?, owner?}], up to 60 cards. facts: worn, never, stale, recent, dup, nofit, maybe, sentimental, record, safety, elsewhere, borrowed. by: page, none, you, ruling.
askstring (JSON)The ids to rule on: every card with by page or none.
countsstring (JSON){toss, donate, rehome, keep, hold, stuck, target} in quantities; target is 12.
goalstringOptional, the person's words; past 600 characters the middle is cut around a marker.
retry_notestringOnly on a reformat retry.

The reply

The job's output.output is a string holding one JSON object. Parse it with BinKit.parseReply, then normalizeRule, then check it with plan(reply, state, today), which returns {moves, notes, after}: the moves the page would make with its guards enforced, the notes (ok, info, warn, bad) and the counts after applying.

Worked examples

Replies produced against the app's prompt for the sample zones (made-up items; lists shortened; the full replies ship in example-result.js). The page replays each of them free from its sample buttons. Inputs are shown with the long fields folded.

task: rule - Hall closet before winter

Input:

{
 "task": "rule",
 "zone": "Hall closet",
 "today": "2026-10-19",
 "items": "[{\"id\":\"I1\",\"text\":\"Winter coat - wore it last winter\",\"qty\":1,\"facts\":[\"recent\"],\"bin\":\"keep\",\"by\":\"page\",\"page_why\":\"In use.\"},{\"id\":\"I2\",\"text\":\"Kids' snow boots x2, outgrown\",\"qty\":2,\"facts\":[\"nofit\"],\"bin\":\"donate\",\"by\":\"page\",\"page_why\":\"It does not fit or suit you - it may suit someone else.\"},\"... 14 more cards\"]",
 "ask": "[\"I1\",\"I2\",\"I3\",\"I4\",\"I5\",\"I6\",\"I7\",\"I8\",\"I9\",\"I10\",\"I11\",\"I12\",\"I13\",\"I14\",\"I15\",\"I16\"]",
 "counts": "{\"toss\":9,\"donate\":23,\"rehome\":2,\"keep\":3,\"hold\":1,\"stuck\":3,\"target\":12}",
 "goal": "Winter is coming and I need room for the coats and boots we actually wear. Two kids, one closet."
}

Reply:

{
 "task": "rule",
 "title": "Hall Closet: Room For The Coats You Wear",
 "summary": "After these rulings, toss sits at 9 of the 12 target (short by 3) and rehome at just 2 of 12 (short by 10, since almost nothing here actually belongs somewhere else) - donate has already passed its mark at 23, mostly the fourteen tote bags and five spare umbrellas. The biggest win is clearing those duplicates along with the outgrown kids' boots and the too-small ski jacket, freeing real space for the coats and boots you still wear. Grandma's scarf, last year's costume, the extension cord, and the unmatched vacuum attachments go in the hold box; the fire extinguisher and winter car kit stay put as safety items.",
 "rulings": [
  {
   "id": "I1",
   "bin": "keep",
   "why": "You wore this coat last winter, so it is in use.",
   "guard": "",
   "rehome_to": "",
   "hold_days": null,
   "cite": [
    "I1"
   ]
  },
  {
   "id": "I2",
   "bin": "donate",
   "why": "The kids have outgrown these, so a child who still fits them can use them.",
   "guard": "",
   "rehome_to": "",
   "hold_days": null,
   "cite": [
    "I2",
    "N-goal"
   ]
  },
  {
   "id": "I3",
   "bin": "toss",
   "why": "Seven single gloves with no pairs left - nobody else can use them either.",
   "guard": "",
   "rehome_to": "",
   "hold_days": null,
   "cite": [
    "I3"
   ]
  },
  "..."
 ],
 "second_looks": [],
 "first_fifteen": [
  "Bag the 7 single gloves and 2 broken umbrellas for the trash.",
  "Box the 14 tote bags, 5 extra umbrellas, outgrown snow boots, too-small ski jacket, and old ice skates for donation.",
  "Carry the rain boots to the mudroom and the casserole dish back to the neighbor.",
  "..."
 ],
 "dont_buy": "Clearing nine toss items and twenty-three donations should free enough space in the one closet - no new bins or shelving needed.",
 "caveats": [
  "This rules only on what the lines say, not the physical condition of each item.",
  "The vacuum attachments are held rather than donated in case the matching vacuum turns up - check before deciding."
 ]
}

task: rule - Garage shelf

Input:

{
 "task": "rule",
 "zone": "Garage shelf",
 "today": "2026-10-19",
 "items": "[{\"id\":\"I1\",\"text\":\"Paint cans x4 - dried out\",\"qty\":4,\"facts\":[\"worn\",\"dup\"],\"bin\":\"toss\",\"by\":\"page\",\"page_why\":\"Broken or worn out - nobody else can use it either.\"},{\"id\":\"I2\",\"text\":\"Drill - borrowed from my neighbor Sam\",\"qty\":1,\"facts\":[\"borrowed\"],\"bin\":\"rehome\",\"by\":\"page\",\"page_why\":\"It belongs to your neighbor - give it back.\",\"owner\":\"neighbor\"},\"... 12 more cards\"]",
 "ask": "[\"I1\",\"I2\",\"I3\",\"I4\",\"I5\",\"I6\",\"I7\",\"I8\",\"I9\",\"I10\",\"I11\",\"I12\",\"I13\",\"I14\"]",
 "counts": "{\"toss\":11,\"donate\":6,\"rehome\":2,\"keep\":2,\"hold\":0,\"stuck\":4,\"target\":12}"
}

Reply:

{
 "task": "rule",
 "title": "Garage Shelf: Toss Hits Twelve, Rehome Lags",
 "summary": "Toss already hits its twelve - the dried-out paint, the cracked cooler, the two dead light strands, and the router's box and manual add up to 12 - the biggest win on this shelf. Donate sits at 6 of 12, six short, and rehome at 2 of 12, ten short, so there's more sorting ahead once everything else is settled. The floor tiles, the mystery screws, and the old license plates have no evidence either way, so they go in a dated hold box instead of a guess. The first-aid kit stays guarded in keep as a safety item - replace it, then dispose of the expired one safely.",
 "rulings": [
  {
   "id": "I1",
   "bin": "toss",
   "why": "Dried-out paint can't be donated or recycled as ordinary trash - it needs a hazardous-waste drop-off.",
   "guard": "",
   "rehome_to": "",
   "hold_days": null,
   "cite": [
    "I1"
   ]
  },
  {
   "id": "I2",
   "bin": "rehome",
   "why": "It's borrowed from neighbor Sam, not yours to keep - give it back.",
   "guard": "",
   "rehome_to": "Sam",
   "hold_days": null,
   "cite": [
    "I2"
   ]
  },
  {
   "id": "I3",
   "bin": "donate",
   "why": "Never used, so someone else can get the use out of it.",
   "guard": "",
   "rehome_to": "",
   "hold_days": null,
   "cite": [
    "I3"
   ]
  },
  "..."
 ],
 "second_looks": [],
 "first_fifteen": [
  "Bag the cracked cooler and the two dead light strands for the trash.",
  "Carry the drill back to Sam.",
  "Set the car wax back in the shed.",
  "..."
 ],
 "dont_buy": "Once these items clear the shelf there's space to spare, so skip buying any new bins or organizers.",
 "caveats": [
  "This rules only on what the lines say - check local hazardous-waste rules for the paint and e-waste rules for the lights before tossing them.",
  "Keep the old first-aid kit in place until its replacement is actually in hand, not just on order."
 ]
}

task: rule - Memory drawer (badly sorted) - second looks

Input:

{
 "task": "rule",
 "zone": "Junk and memory drawer",
 "today": "2026-10-19",
 "items": "[{\"id\":\"I1\",\"text\":\"Grandpa's pocket watch - doesn't work\",\"qty\":1,\"facts\":[\"worn\",\"sentimental\"],\"bin\":\"toss\",\"by\":\"you\"},{\"id\":\"I2\",\"text\":\"Birth certificate copy\",\"qty\":1,\"facts\":[\"record\"],\"bin\":\"toss\",\"by\":\"you\"},\"... 11 more cards\"]",
 "ask": "[\"I3\",\"I4\",\"I5\",\"I7\",\"I8\",\"I9\",\"I10\",\"I11\",\"I12\",\"I13\"]",
 "counts": "{\"toss\":31,\"donate\":19,\"rehome\":0,\"keep\":2,\"hold\":71,\"stuck\":1,\"target\":12}",
 "goal": "I want the drawer to close. I already threw a few things in the toss pile."
}

Reply:

{
 "task": "rule",
 "title": "Drawer Clears Out, Memories Wait",
 "summary": "Toss ends up well past its twelve at 50 items once the instruction manuals, takeout menus, dead phones, dried-out pens and old batteries join it, while donate and rehome both sit at zero - twelve short each, since nothing here turned out to be truly donatable or belongs to another room. The biggest win is clearing all of that out without touching anything load-bearing. Sentimental keepsakes - the birthday cards, the kids' art, grandpa's watch - and both records - the birth certificate and the fridge warranty - are boxed on hold rather than removed, and the spare keys stay in keep as a safety item.",
 "rulings": [
  {
   "id": "I3",
   "bin": "hold",
   "why": "These 40 birthday cards are a sentimental duplicate, so they're boxed for now - keep a few favourites and photograph the rest.",
   "guard": "sentimental",
   "rehome_to": "",
   "hold_days": 30,
   "cite": [
    "I3"
   ]
  },
  {
   "id": "I4",
   "bin": "hold",
   "why": "This warranty is for a fridge you've already sold, but it's still a record, so it's boxed until you check how long this kind of paper should be kept.",
   "guard": "record",
   "rehome_to": "",
   "hold_days": 30,
   "cite": [
    "I4"
   ]
  },
  {
   "id": "I5",
   "bin": "toss",
   "why": "Three dead phones are broken and unusable by anyone else - they go to e-waste recycling.",
   "guard": "",
   "rehome_to": "",
   "hold_days": null,
   "cite": [
    "I5"
   ]
  },
  "..."
 ],
 "second_looks": [
  {
   "id": "I1",
   "to": "hold",
   "why": "Grandpa's watch is sentimental even though it's broken - worth boxing instead of tossing it outright.",
   "cite": [
    "I1"
   ]
  },
  {
   "id": "I2",
   "to": "hold",
   "why": "A birth certificate copy is a record, so it deserves a hold to check what's needed before it goes, not a straight toss.",
   "cite": [
    "I2"
   ]
  }
 ],
 "first_fifteen": [
  "Bag the 3 dead phones for e-waste recycling.",
  "Recycle the 9 instruction manuals and 10 takeout menus.",
  "Sort the 25 pens, toss the dried-out ones, and drop the loose batteries at a hazardous-waste collection point.",
  "..."
 ],
 "dont_buy": "Once the manuals, menus, pens, phones and batteries clear out, the drawer should close on its own - no new bin or organiser needed.",
 "caveats": [
  "This reads only the lines you entered, not what's actually inside each hold box.",
  "Check local rules before dropping off batteries, phones or paint.",
  "How long to keep the fridge warranty depends on where you live - check before shredding it."
 ]
}

Build the input yourself

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

const B = BinKit;
const st = B.state({
  zone: "Hall closet",
  list: "Umbrellas x5\nRain boots - belongs in the mudroom\nGrandma's scarf\nExtension cord, might need it",
  goal: "Room for the winter coats.",
  placed: {}                        // { "line text, lowercased": { bin: "keep", by: "you" } }
}, 2026);
const { input } = B.buildInput(st, { today: "2026-10-19" });
const wire = B.toWire(input);       // POST this to /estimate and /run
// later: const reply = B.normalizeRule(B.parseReply(job.output.output));
// const { moves, notes, after } = B.plan(reply, st, "2026-10-19");

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://twelve-each.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":"twelve-each"}' | 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": "rule", "zone": "...", "today": "2026-10-19", "items": "[...]", "ask": "[...]", "counts": "{...}", "goal": "..."}
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 twelve-each:rule:<inputHash>:a<attempt>, 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="twelve-each:rule:$(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: twelve-each:rule:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json