← Fewer Doors
API

Drive Fewer Doors from your own code

The one metered task, trim, is available over HTTP. It takes who is choosing and what for, the options about to be offered - three to twenty numbered lines - and how the choice will be made (said out loud, read at a glance, or read at leisure), and returns a short list that fits that way of choosing, one option to suggest if the chooser cannot decide, headings when every option must stay on offer, the options that would be the same choice, a reason per option offered and a line to offer the choice with.

The division of labour first: the page draws the street of doors, works out the limit for the way of choosing, the illustrative choice load and a guess at repeats; the model judges which options fit the goal, which one to suggest, how to group them, which repeats are real, and words the reasons and the offer line. The page reads the options (bullets and numbering stripped, a * at either end of a line marks one that must be offered), works out how many to offer at once and which pairs may say the same thing, and sends both. After the reply it decides itself which doors are open - never more than the limit, never an option lost - and re-checks every part against the list. Your code can do the same with the engine files the page loads (see "Build the input yourself").

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":"fewer-doors"}), 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 (task, goal, options, option_count, must_offer, mode, limit, keep_all, group_cap, likely_repeats) 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 trim run is metered and needs a personal sign-in.
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, the input and the rules

Every run sends task: trim (the one task is trim); a missing or different one is still answered as trim. All fields are strings.

FieldRequiredWhat it holds
taskyesAlways trim.
goalyesWho is choosing and what for, in the person's words: 8 to 160 characters.
optionsyesNumbered lines N. the option, three to twenty, at most 70 characters each, in the order the person wrote them. Build it with ChoiceKit.buildInput, which strips bullets and numbering and reads the stars.
option_countyesHow many lines options has, as a string.
must_offeryesThe line numbers the person starred as must-offer, comma-separated (1, 6), or none. At most limit of them.
modeyesHow the choice is made: spoken (said out loud), glance (read at a glance, on a sign or a screen) or leisure (read at leisure, like a menu or a form).
limityesHow many options to offer at once for that mode (see the table below): 3, 4 or 7.
keep_allyesyes if every option must stay on offer (then they are grouped under headings), else no.
group_capyesThe most headings allowed when grouping: always 4. With keep_all yes, the list may hold at most limit × 4 options.
likely_repeatsyesPairs of lines the page guesses may say the same thing, e.g. 2 and 5; 3 and 9, or none: lines whose content words overlap by half or more, at most four pairs. Only a guess - the run decides which are real.
retry_notenoOnly on a reformat retry: why the previous reply was unusable.

The limit by way of choosing, and the illustrative choice load at that limit. Hick's law says the time to choose grows with log2(n + 1) for n equally likely options; the page shows that figure for the whole list and for what is offered (twenty options is 4.4 bits). It is an illustration on the page only and is never sent.

modeWay of choosinglimitChoice load at the limitMost options with keep_all yes
spokenSaid out loud32.0 bits12
glanceRead at a glance (a sign, a screen)42.3 bits16
leisureRead at leisure (a menu, a form)73.0 bits28

The reply takes the first verdict that applies: keep when option_count is no more than limit - every option stays on offer and one is suggested; group when keep_all is yes - every option stays on offer under 2 to 4 headings of 2 to limit options each; else trim - exactly limit options are offered, every must-offer line among them, and the rest stay available on request.

The reply

One JSON object:

{"task": "trim", "verdict": "trim", "shortlist": [4, 1, 7], "pick": 4, "picks": [{"n": 4, "why": "..."}], "groups": [], "merge": [{"items": [2, 6], "as": "..."}], "opener": "...", "note": "..."}
FieldWhat it holds
verdictkeep, group or trim.
shortlistThe line numbers to offer at once, best for the goal first: every line for keep, exactly limit for trim (every must-offer line included), [] for group.
pickOne line number to suggest if the chooser cannot decide; for keep and trim it is on the short list, and a must-offer line when there are any.
picks[]For keep and trim: {n, why}, one per short-list line in the same order - what that option brings that the others do not. [] for group.
groups[]For group only: {label, items}, 2 to 4 headings of one to three words, every line in exactly one heading. [] otherwise.
merge[]At most three {items: [a, b], as}: pairs that would be the same choice for the chooser, with one wording that covers both. Never both of a pair on a trimmed short list.
openerOne sentence, at most 25 words, offering the choice in the person's voice: it names every option offered (or every heading) and ends by suggesting the pick.
noteOne sentence on why the verdict fits.

The page reads the reply with ReplyKit.normalize, decides the doors with ReplyKit.doors(reply, form) and checks it with ReplyKit.check(reply, built, form). doors returns {offer, rest, pick, groups}, sanitised so a bad reply can never hide an option or open more than the limit: for keep, every option is on offer (the short-list order first, then any it left out); for trim, the first limit real short-list lines are on offer and every other option is on request; for group, every option once under at most 4 headings of at most limit, with anything left out added under a "More" heading; for an unreadable verdict, everything stays on offer. The pick is the reply's when it is on offer, else the first door. check lists, under "The page's check", a missing verdict or one the counts do not call for, options the list does not have, a trimmed short list that is not exactly limit long or leaves out a must-offer line, headings outside 2 to 4, an option left out of or repeated across headings, a heading of one option or more than limit or over three words, a missing pick or one not on offer, missing or long reasons, a merge naming a missing option or a short list offering both of a merged pair, a missing offer line or one that does not name what is offered or the pick, and any number or internal field name the list never gives.

Worked examples - every sample

These are the free samples on the page: the exact input the page sends, the reply that replays for free, and what the page does with it - the doors ReplyKit.doors opens and its own check. Together they cover the verdicts trim, group, keep.

Sunday dinner, asked by phone

Input:

{
 "task": "trim",
 "goal": "Asking my in-laws over the phone what they would like me to cook for Sunday lunch",
 "options": "1. roast chicken\n2. lasagne\n3. fish pie\n4. vegetable curry\n5. shepherd's pie\n6. roast chicken with stuffing\n7. a big salad\n8. chilli\n9. roast lamb",
 "option_count": "9",
 "must_offer": "none",
 "mode": "spoken",
 "limit": "3",
 "keep_all": "no",
 "group_cap": "4",
 "likely_repeats": "1 and 6"
}

Reply:

{
 "task": "trim",
 "verdict": "trim",
 "shortlist": [
  1,
  5,
  4
 ],
 "pick": 1,
 "picks": [
  {
   "n": 1,
   "why": "Roast chicken gives your in-laws a roast built on chicken for Sunday lunch."
  },
  {
   "n": 5,
   "why": "Shepherd's pie gives your in-laws a pie to offer for Sunday lunch."
  },
  {
   "n": 4,
   "why": "Vegetable curry gives your in-laws a curry made of vegetables for Sunday lunch."
  }
 ],
 "groups": [],
 "merge": [
  {
   "items": [
    1,
    6
   ],
   "as": "roast chicken, with or without stuffing"
  }
 ],
 "opener": "Would you like roast chicken, shepherd's pie, or vegetable curry for Sunday lunch, or shall I go with roast chicken?",
 "note": "This offers three options at once for Sunday lunch, and the rest stay available if your in-laws ask for them."
}

The page's doors (ReplyKit.doors gives offer [1, 5, 4], on request [2, 3, 6, 7, 8, 9], pick 1; limit 3):

On offer:

  1. roast chicken - suggested - Roast chicken gives your in-laws a roast built on chicken for Sunday lunch.
  2. shepherd's pie - Shepherd's pie gives your in-laws a pie to offer for Sunday lunch.
  3. vegetable curry - Vegetable curry gives your in-laws a curry made of vegetables for Sunday lunch.

On request:

Could be one option:

Choice load (illustrative): all 9 options is 3.3 bits; offering 3 at once is 2.0 bits.

The page's check: kept.

No notes.

A tea-tent drinks board

Input:

{
 "task": "trim",
 "goal": "The drinks board at our village fete tea tent, read by people while they queue",
 "options": "1. tea\n2. coffee\n3. decaf coffee\n4. herbal tea\n5. hot chocolate\n6. orange squash\n7. lemonade\n8. apple juice\n9. bottled water\n10. iced coffee",
 "option_count": "10",
 "must_offer": "none",
 "mode": "leisure",
 "limit": "7",
 "keep_all": "yes",
 "group_cap": "4",
 "likely_repeats": "1 and 4; 2 and 3; 2 and 10"
}

Reply:

{
 "task": "trim",
 "verdict": "group",
 "shortlist": [],
 "pick": 1,
 "picks": [],
 "groups": [
  {
   "label": "Hot drinks",
   "items": [
    1,
    4,
    5
   ]
  },
  {
   "label": "Coffees",
   "items": [
    2,
    3,
    10
   ]
  },
  {
   "label": "Cold drinks",
   "items": [
    6,
    7,
    8,
    9
   ]
  }
 ],
 "merge": [],
 "opener": "You can choose from hot drinks, coffees or cold drinks - maybe start with the tea?",
 "note": "You keep all the drinks on offer, grouped under three headings so people can read the board easily while they queue."
}

The page's doors (ReplyKit.doors gives offer [1, 4, 5, 2, 3, 10, 6, 7, 8, 9], on request [], pick 1; limit 7):

Under headings (every option stays on offer):

Choice load (illustrative): all 10 options is 3.5 bits; 3 headings at once is 2.0 bits.

The page's check: kept.

No notes.

Where the book club meets

Input:

{
 "task": "trim",
 "goal": "Giving my book club a choice of where to meet next month, in our group message",
 "options": "1. the library cafe\n2. the pub by the river\n3. my flat",
 "option_count": "3",
 "must_offer": "1",
 "mode": "glance",
 "limit": "4",
 "keep_all": "no",
 "group_cap": "4",
 "likely_repeats": "none"
}

Reply:

{
 "task": "trim",
 "verdict": "keep",
 "shortlist": [
  1,
  2,
  3
 ],
 "pick": 1,
 "picks": [
  {
   "n": 1,
   "why": "The library cafe sits at a library."
  },
  {
   "n": 2,
   "why": "The pub by the river sits beside a river."
  },
  {
   "n": 3,
   "why": "Your flat lets your book club meet at your own home."
  }
 ],
 "groups": [],
 "merge": [],
 "opener": "Where should we meet next month - the library cafe, the pub by the river, or my flat? I'd go with the library cafe.",
 "note": "You have few enough options to offer your book club all at once."
}

The page's doors (ReplyKit.doors gives offer [1, 2, 3], on request [], pick 1; limit 4):

On offer:

  1. the library cafe (must offer) - suggested - The library cafe sits at a library.
  2. the pub by the river - The pub by the river sits beside a river.
  3. my flat - Your flat lets your book club meet at your own home.

Choice load (illustrative): all 3 options is 2.0 bits; offering 3 at once is 2.0 bits.

The page's check: kept.

No notes.

Build the input yourself

Load the engine in node (global.window = global; require("./choicekit.js"); require("./replykit.js")), then:

const K = ChoiceKit, R = ReplyKit;
const form = K.form({ goal: "Asking a friend on the phone where to go for lunch today", options: "the noodle bar\n* the pizza place\nthe sandwich shop\nthe cafe in the park", mode: "spoken", keepAll: "no" });
const built = K.buildInput(form);     // built.ok is false, with built.note, until there is a goal and three options
const wire = K.toWire(built.input);   // POST this to /estimate and /run
// K.load(form.lines.length)          // the illustrative choice load in bits, log2(n + 1)
// then: const reply = R.normalize(R.parseReply(job.output.output));
// R.doors(reply, form)               // {offer, rest, pick, groups}: the doors the page opens
// R.check(reply, built, form).notes  // the page's re-check of every part

Form keys: goal (who is choosing and what for), options (one per line, three to twenty, 70 characters each; a * at either end marks one that must be offered), mode (spoken, glance or leisure) and keepAll (yes or no).

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://fewer-doors.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":"fewer-doors"}' | 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": "trim", "goal": "Asking a friend on the phone where to go for lunch today", "options": "1. the noodle bar\n2. the pizza place\n3. the sandwich shop\n4. the cafe in the park", "option_count": "4", "must_offer": "none", "mode": "spoken", "limit": "3", "keep_all": "no", "group_cap": "4", "likely_repeats": "none"}
# - see the field table
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 fewer-doors:<task>:<inputHash>:a<attempt> (the one task is trim), 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="fewer-doors:trim:$(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: fewer-doors:trim:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json