← Second Choice
API

Drive Second Choice from your own code

The one metered task, explain-the-count, is available over HTTP. It takes a small group's question, its options, its anonymous ranked ballots and the page's finished instant-runoff count (one line per round, plus the first-choice leader, the second choices and the one-on-one match-ups), and returns an outcome type, a note for every round, a second-choice note, a cross-check, a decision record, one next step for the organiser and a closing line.

The division of labour first: the count is made by the page (or by your own code with TallyKit.buildInput), never by the model. The run explains the numbers it is given, and the outcome type follows a fixed order from those numbers.

The token is minted for this app (the guest call takes {"slug":"second-choice"}), 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 is missing (every field below except retry_note).
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 decision record 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 outcome types

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

FieldRequiredWhat it holds
taskyesAlways explain-the-count.
questionyesWhat the group is choosing (the page sends at most 120 characters, cut at a word break).
optionsyes2 to 6 options, numbered, one per line: 1. Sage.
ballotsyesFirst line N ballots, then one line per distinct ranking: 3 x Sage > Oat (at most 60 lines; any rest is summarised in one line). Ballots are anonymous.
roundsyesOne line per round: Round R | option votes, ... | in the count C, exhausted E | eliminated X (reason), its ballots moved: Y +n, or result: X wins with V of C (more than half), or result: tie between X and Y at V each.
first_choice_leaderyesThe option or options with the most round-1 votes, with that number.
second_choicesyesBallots ranking each option second, then no second choice N.
head_to_headyesEvery pair compared one-on-one (Sage beats Oat 4-2), then beats every other option one-on-one: X or none.
retry_notenoOnly for a reformat retry: why the previous reply was unusable.

The reply is one JSON object:

{"outcome": "comeback", "headline": "...", "rounds": [{"round": 1, "note": "..."}, ...], "second_choices": "...", "cross_check": "...", "record": "...", "next_step": "...", "last_word": "..."}
FieldWhat it holds
outcomeFirst that applies: dead-heat (the last round is a tie), first-round-majority (one round), comeback (another option had more round-1 votes than the winner), photo-finish (final gap of 1 or less, or gap x 10 at most the final count), won-on-transfers.
headlineThe result with the winner's final votes and the final count in digits, or both tied options (at most 26 words).
roundsOne object per round, in order: round and a note of at most 28 words using only that round's numbers.
second_choicesWhat the second preferences show (at most 35 words).
cross_checkWhether the result matches the first-choice leader and the one-on-one winner, and any exhausted ballots (at most 40 words).
recordThe decision record: question, winner or "no decision yet", ballots, rounds, final count (at most 35 words).
next_stepOne practical step for the organiser that fits the outcome (at most 30 words); for a dead heat it never picks a side.
last_wordOne line that agrees with the outcome (at most 18 words).

The page then checks the reply (TallyKit.check) and lists any disagreement under "The page's check". Worked examples, one per outcome type:

The hallway paint - 7 ballots, 1 round: first-round majority

Input:

{
 "task": "explain-the-count",
 "question": "Paint colour for the shared hallway",
 "options": "1. Sage\n2. Oat\n3. Slate\n4. Terracotta",
 "ballots": "7 ballots\n3 x Sage > Oat\n2 x Oat > Sage > Slate\n1 x Sage > Slate > Oat\n1 x Terracotta > Slate",
 "rounds": "Round 1 | Sage 4, Oat 2, Terracotta 1, Slate 0 | in the count 7, exhausted 0 | result: Sage wins with 4 of 7 (more than half)",
 "first_choice_leader": "Sage (4)",
 "second_choices": "Oat 3, Sage 2, Slate 2, Terracotta 0; no second choice 0",
 "head_to_head": "Sage beats Oat 4-2; Sage beats Slate 6-1; Sage beats Terracotta 6-1; Oat beats Slate 5-2; Oat beats Terracotta 6-1; Slate beats Terracotta 3-1\nbeats every other option one-on-one: Sage"
}

Reply:

{
 "outcome": "first-round-majority",
 "headline": "Sage wins the hallway paint colour vote with 4 of the 7 ballots in the count in round 1.",
 "rounds": [
  {
   "round": 1,
   "note": "Sage led with 4 of the 7 ballots in the count and passed half, deciding the vote in round 1 with no elimination needed."
  }
 ],
 "second_choices": "Oat was ranked second most often, with 3 ballots naming it as second choice; Sage and Slate each got 2 second-choice ballots, and 0 ballots gave no second choice.",
 "cross_check": "The result matches the option with the most first choices, Sage, and Sage is also the option that beats every other option one-on-one; the last round had 0 exhausted ballots.",
 "record": "The group's decision for paint colour for the shared hallway is Sage, from 7 ballots counted in 1 round, with a final count of 4 of 7.",
 "next_step": "Share with the group that Sage passed half the first-choice ballots in round 1, so no transfers were needed to decide this one.",
 "last_word": "One option, Sage, had more than half of the first-choice votes from the start."
}

The team lunch - 11 ballots, 3 rounds: comeback win

Input:

{
 "task": "explain-the-count",
 "question": "Where the team goes for the monthly lunch",
 "options": "1. Noodle bar\n2. Pizza place\n3. Salad bar\n4. Burrito truck",
 "ballots": "11 ballots\n4 x Pizza place > Burrito truck\n3 x Noodle bar > Salad bar > Pizza place\n2 x Salad bar > Noodle bar\n2 x Burrito truck > Noodle bar > Pizza place",
 "rounds": "Round 1 | Pizza place 4, Noodle bar 3, Salad bar 2, Burrito truck 2 | in the count 11, exhausted 0 | eliminated Burrito truck (tied for fewest with Salad bar, same first-round votes, listed later), its ballots moved: Noodle bar +2\nRound 2 | Noodle bar 5, Pizza place 4, Salad bar 2 | in the count 11, exhausted 0 | eliminated Salad bar (fewest votes), its ballots moved: Noodle bar +2\nRound 3 | Noodle bar 7, Pizza place 4 | in the count 11, exhausted 0 | result: Noodle bar wins with 7 of 11 (more than half)",
 "first_choice_leader": "Pizza place (4)",
 "second_choices": "Noodle bar 4, Burrito truck 4, Salad bar 3, Pizza place 0; no second choice 0",
 "head_to_head": "Noodle bar beats Pizza place 7-4; Noodle bar beats Salad bar 5-2; Burrito truck beats Noodle bar 6-5; Pizza place beats Salad bar 6-5; Pizza place beats Burrito truck 7-2; Burrito truck beats Salad bar 6-5\nbeats every other option one-on-one: none"
}

Reply:

{
 "outcome": "comeback",
 "headline": "Noodle bar wins with 7 votes in the final round, out of 11 ballots in the count, after trailing in the first round.",
 "rounds": [
  {
   "round": 1,
   "note": "Pizza place led round 1 with 4 votes; Burrito truck, tied with Salad bar for fewest votes but listed later, was eliminated, moving 2 ballots to Noodle bar."
  },
  {
   "round": 2,
   "note": "Noodle bar led round 2 with 5 votes; Salad bar had the fewest and was eliminated, moving 2 ballots to Noodle bar."
  },
  {
   "round": 3,
   "note": "Noodle bar passed half with 7 of 11 votes in round 3, ending the count."
  }
 ],
 "second_choices": "Noodle bar and Burrito truck were each ranked second by 4 ballots, the most of any option; Salad bar got 3, Pizza place got 0; 0 ballots gave no second choice.",
 "cross_check": "The winner differs from the first-round leader, Pizza place; comparing options one-on-one, no option beats every other, including Noodle bar. The last round had 0 exhausted ballots.",
 "record": "Decision record: for where the team goes for the monthly lunch, Noodle bar won after 3 rounds counting 11 ballots, with a final count of 7 to 4 for Pizza place.",
 "next_step": "Announce the result by walking through the 3 rounds in order, showing how Burrito truck's and Salad bar's ballots transferred to Noodle bar before it passed half.",
 "last_word": "Noodle bar trailed in first choices but gained enough later preferences to come out ahead."
}

The bike-shed mural - 9 ballots, 2 rounds: photo finish

Input:

{
 "task": "explain-the-count",
 "question": "Theme for the bike shed mural",
 "options": "1. Sea\n2. Forest\n3. City skyline",
 "ballots": "9 ballots\n4 x Sea > Forest\n3 x Forest > City skyline\n1 x City skyline > Sea\n1 x City skyline > Forest",
 "rounds": "Round 1 | Sea 4, Forest 3, City skyline 2 | in the count 9, exhausted 0 | eliminated City skyline (fewest votes), its ballots moved: Sea +1, Forest +1\nRound 2 | Sea 5, Forest 4 | in the count 9, exhausted 0 | result: Sea wins with 5 of 9 (more than half)",
 "first_choice_leader": "Sea (4)",
 "second_choices": "Forest 5, City skyline 3, Sea 1; no second choice 0",
 "head_to_head": "Sea beats Forest 5-4; City skyline beats Sea 5-4; Forest beats City skyline 7-2\nbeats every other option one-on-one: none"
}

Reply:

{
 "outcome": "photo-finish",
 "headline": "Sea wins the vote on the bike shed mural theme, finishing with 5 of 9 ballots in the count.",
 "rounds": [
  {
   "round": 1,
   "note": "Sea led with 4, Forest had 3 and City skyline had 2; City skyline was eliminated, moving Sea +1 and Forest +1."
  },
  {
   "round": 2,
   "note": "Sea led with 5 to Forest's 4, and Sea passed half of the 9 ballots in the count to win."
  }
 ],
 "second_choices": "Forest was ranked second most often, with 5 ballots, ahead of City skyline's 3 and Sea's 1; every ballot gave a second choice, with 0 giving none.",
 "cross_check": "The result agrees with the first-choice leader, Sea, who had the most first choices. But the one-on-one comparisons show no option beats every other option, so Sea is not that option. The last round had 0 exhausted ballots.",
 "record": "For the bike shed mural theme, the group chose Sea after 9 ballots and 2 rounds, with a final count of Sea 5 to Forest 4.",
 "next_step": "Check that every ballot was entered exactly once before you announce the result, since round 2 ended with Sea 5 to Forest 4.",
 "last_word": "The final round ended with a gap of 1 vote between Sea and Forest."
}

The club's next genre - 12 ballots, 3 rounds: won on transfers

Input:

{
 "task": "explain-the-count",
 "question": "Next genre for the book club",
 "options": "1. Mystery\n2. Memoir\n3. Science fiction\n4. Poetry",
 "ballots": "12 ballots\n5 x Mystery > Memoir\n3 x Memoir > Mystery\n2 x Science fiction > Mystery > Memoir\n1 x Poetry > Memoir\n1 x Poetry",
 "rounds": "Round 1 | Mystery 5, Memoir 3, Science fiction 2, Poetry 2 | in the count 12, exhausted 0 | eliminated Poetry (tied for fewest with Science fiction, same first-round votes, listed later), its ballots moved: Memoir +1, exhausted +1\nRound 2 | Mystery 5, Memoir 4, Science fiction 2 | in the count 11, exhausted 1 | eliminated Science fiction (fewest votes), its ballots moved: Mystery +2\nRound 3 | Mystery 7, Memoir 4 | in the count 11, exhausted 1 | result: Mystery wins with 7 of 11 (more than half)",
 "first_choice_leader": "Mystery (5)",
 "second_choices": "Memoir 6, Mystery 5, Science fiction 0, Poetry 0; no second choice 1",
 "head_to_head": "Mystery beats Memoir 7-4; Mystery beats Science fiction 8-2; Mystery beats Poetry 10-2; Memoir beats Science fiction 9-2; Memoir beats Poetry 10-2; Science fiction ties Poetry 2-2\nbeats every other option one-on-one: Mystery"
}

Reply:

{
 "outcome": "won-on-transfers",
 "headline": "Mystery wins with 7 votes in the last round, out of 11 ballots still in the count.",
 "rounds": [
  {
   "round": 1,
   "note": "Mystery led with 5 votes; Poetry was eliminated after tying Science fiction for fewest, broken by listing order, moving 1 ballot to Memoir and exhausting 1."
  },
  {
   "round": 2,
   "note": "Mystery still led with 5 votes; Science fiction, with the fewest votes, was eliminated, and its 2 ballots moved to Mystery."
  },
  {
   "round": 3,
   "note": "Mystery led with 7 votes and passed half of the 11 ballots in the count, winning the final round outright."
  }
 ],
 "second_choices": "Memoir was ranked second most often, with 6 ballots, ahead of Mystery's 5; Science fiction and Poetry each got 0 second choices, and 1 ballot gave none.",
 "cross_check": "The result matches the first-choice leader, Mystery, which also beats every other option head-to-head; the last round had 1 exhausted ballot.",
 "record": "For the next genre for the book club, the group chose Mystery after 12 ballots and 3 rounds, with a final count of 7 of 11.",
 "next_step": "Announce that Mystery won after transfers from Poetry and Science fiction, showing each round's counts so the group can follow how the result was reached.",
 "last_word": "The first-choice leader needed later preferences from eliminated options to pass half the ballots."
}

The big mirror - 6 ballots, 2 rounds: dead heat

Input:

{
 "task": "explain-the-count",
 "question": "Where the big mirror goes in the flat",
 "options": "1. Hallway\n2. Living room\n3. Bedroom landing",
 "ballots": "6 ballots\n2 x Hallway > Living room\n2 x Living room > Hallway\n1 x Bedroom landing > Hallway\n1 x Bedroom landing > Living room",
 "rounds": "Round 1 | Hallway 2, Living room 2, Bedroom landing 2 | in the count 6, exhausted 0 | eliminated Bedroom landing (tied for fewest with Hallway and Living room, same first-round votes, listed later), its ballots moved: Hallway +1, Living room +1\nRound 2 | Hallway 3, Living room 3 | in the count 6, exhausted 0 | result: tie between Hallway and Living room at 3 each",
 "first_choice_leader": "Hallway, Living room and Bedroom landing (2 each)",
 "second_choices": "Hallway 3, Living room 3, Bedroom landing 0; no second choice 0",
 "head_to_head": "Hallway ties Living room 3-3; Hallway beats Bedroom landing 4-2; Living room beats Bedroom landing 4-2\nbeats every other option one-on-one: none"
}

Reply:

{
 "outcome": "dead-heat",
 "headline": "Hallway and Living room tied at 3 votes each after round 2; no option has won yet with all 6 ballots in the count.",
 "rounds": [
  {
   "round": 1,
   "note": "All 3 options tied for the lead at 2 votes; Bedroom landing was eliminated as listed later, moving 1 ballot to Hallway and 1 to Living room."
  },
  {
   "round": 2,
   "note": "Hallway and Living room led evenly with 3 votes each, and the round ended in a tie between them with no result yet."
  }
 ],
 "second_choices": "Hallway and Living room were each ranked second on 3 ballots, Bedroom landing on 0, and 0 ballots gave no second choice.",
 "cross_check": "Both tied options were first-round leaders, though Bedroom landing also led then; head-to-head shows no option beats every other one-on-one, and the last round had 0 exhausted ballots.",
 "record": "For where the big mirror goes in the flat, there is no decision yet: Hallway and Living room tied at 3 each after 6 ballots and 2 rounds.",
 "next_step": "Agree with the group on how to break the tie between Hallway and Living room before announcing anything, without picking one yourself.",
 "last_word": "The group still has to choose between Hallway and Living room."
}

Build the input yourself

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

const K = TallyKit;
const built = K.buildInput(K.form({
  question: "Which plant goes on the office windowsill",
  options: "Fern\nCactus\nIvy",
  ballots: "2 x Fern > Ivy\nCactus > Ivy\nIvy > Cactus > Fern" }));
const wire = K.toWire(built.input);     // POST this to /estimate and /run
// built.facts.res.outcome               // the page's own outcome type
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes          // the page's re-check of the reply
// K.md(built, reply, "my vote")        // the count and the record as Markdown

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://second-choice.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":"second-choice"}' | 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": "explain-the-count", "question": "...", "options": "1. ...", "ballots": "7 ballots\n3 x A > B", "rounds": "Round 1 | ...", "first_choice_leader": "...", "second_choices": "...", "head_to_head": "..."}
# - 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 second-choice:<task>:<inputHash>:a<attempt> (the one task is explain-the-count), 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="second-choice:explain-the-count:$(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: second-choice:explain-the-count:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json