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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field is missing (every field below except retry_note). |
| 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: the decision record is metered and needs a personal sign-in. |
| 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, 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.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always explain-the-count. |
question | yes | What the group is choosing (the page sends at most 120 characters, cut at a word break). |
options | yes | 2 to 6 options, numbered, one per line: 1. Sage. |
ballots | yes | First 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. |
rounds | yes | One 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_leader | yes | The option or options with the most round-1 votes, with that number. |
second_choices | yes | Ballots ranking each option second, then no second choice N. |
head_to_head | yes | Every pair compared one-on-one (Sage beats Oat 4-2), then beats every other option one-on-one: X or none. |
retry_note | no | Only 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": "..."}
| Field | What it holds |
|---|---|
outcome | First 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. |
headline | The result with the winner's final votes and the final count in digits, or both tied options (at most 26 words). |
rounds | One object per round, in order: round and a note of at most 28 words using only that round's numbers. |
second_choices | What the second preferences show (at most 35 words). |
cross_check | Whether the result matches the first-choice leader and the one-on-one winner, and any exhausted ballots (at most 40 words). |
record | The decision record: question, winner or "no decision yet", ballots, rounds, final count (at most 35 words). |
next_step | One practical step for the organiser that fits the outcome (at most 30 words); for a dead heat it never picks a side. |
last_word | One 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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://second-choice.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"]
// second-choice.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://second-choice.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://second-choice.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
}
// WhatWasThat.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 WhatWasThat {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://second-choice.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://second-choice.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://second-choice.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://second-choice.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":"second-choice"}' | 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": "second-choice"})["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: "second-choice" })).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": "second-choice"}, 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", "second-choice"), 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: "second-choice" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "second-choice"])["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 = "second-choice" }))!["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": "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}'
inp = json.load(open("input.json")) # {"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
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: "explain-the-count", question, options, ballots, rounds, first_choice_leader, second_choices, head_to_head} (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 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}'
import hashlib, time
key = "second-choice:" + 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 reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("outcome"))
import { createHash } from "node:crypto";
const key = `second-choice:${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.outcome);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "second-choice:" + 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 = "second-choice:" + 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 = "second-choice:#{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["outcome"]].inspect
$key = "second-choice:" . $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["outcome"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"second-choice:{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: second-choice:explain-the-count:$(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": "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
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "second-choice:explain-the-count:" + 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": `second-choice:explain-the-count:${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", "second-choice:explain-the-count:"+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", "second-choice:explain-the-count:" + 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" => "second-choice:explain-the-count:#{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: second-choice:explain-the-count:" . 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", $"second-choice:explain-the-count:{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);