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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The 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. |
| 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 trim run 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 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.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always trim. |
goal | yes | Who is choosing and what for, in the person's words: 8 to 160 characters. |
options | yes | Numbered 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_count | yes | How many lines options has, as a string. |
must_offer | yes | The line numbers the person starred as must-offer, comma-separated (1, 6), or none. At most limit of them. |
mode | yes | How 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). |
limit | yes | How many options to offer at once for that mode (see the table below): 3, 4 or 7. |
keep_all | yes | yes if every option must stay on offer (then they are grouped under headings), else no. |
group_cap | yes | The most headings allowed when grouping: always 4. With keep_all yes, the list may hold at most limit × 4 options. |
likely_repeats | yes | Pairs 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_note | no | Only 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.
mode | Way of choosing | limit | Choice load at the limit | Most options with keep_all yes |
|---|---|---|---|---|
spoken | Said out loud | 3 | 2.0 bits | 12 |
glance | Read at a glance (a sign, a screen) | 4 | 2.3 bits | 16 |
leisure | Read at leisure (a menu, a form) | 7 | 3.0 bits | 28 |
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": "..."}
| Field | What it holds |
|---|---|
verdict | keep, group or trim. |
shortlist | The 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. |
pick | One 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. |
opener | One 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. |
note | One 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:
- roast chicken - suggested - Roast chicken gives your in-laws a roast built on chicken for Sunday lunch.
- shepherd's pie - Shepherd's pie gives your in-laws a pie to offer for Sunday lunch.
- vegetable curry - Vegetable curry gives your in-laws a curry made of vegetables for Sunday lunch.
On request:
- lasagne
- fish pie
- roast chicken with stuffing
- a big salad
- chilli
- roast lamb
Could be one option:
- roast chicken + roast chicken with stuffing - roast chicken, with or without stuffing
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):
- Hot drinks: tea - suggested, herbal tea, hot chocolate
- Coffees: coffee, decaf coffee, iced coffee
- Cold drinks: orange squash, lemonade, apple juice, bottled water
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:
- the library cafe (must offer) - suggested - The library cafe sits at a library.
- the pub by the river - The pub by the river sits beside a river.
- 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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://fewer-doors.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"]
// fewer-doors.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://fewer-doors.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://fewer-doors.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
}
// FewerDoors.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 FewerDoors {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://fewer-doors.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://fewer-doors.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://fewer-doors.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://fewer-doors.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":"fewer-doors"}' | 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": "fewer-doors"})["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: "fewer-doors" })).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": "fewer-doors"}, 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", "fewer-doors"), 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: "fewer-doors" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "fewer-doors"])["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 = "fewer-doors" }))!["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": "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}'
inp = json.load(open("input.json")) # {"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
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: "trim", goal, options, option_count, must_offer, mode, limit, keep_all, group_cap, likely_repeats} (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 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}'
import hashlib, time
key = "fewer-doors:" + 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("verdict"), reply.get("shortlist"))
import { createHash } from "node:crypto";
const key = `fewer-doors:${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.verdict, reply.shortlist);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "fewer-doors:" + 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 }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], j["truncated"], reply["verdict"], reply["shortlist"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "fewer-doors:" + 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.get("verdict") + " " + reply.get("shortlist"));
require "digest"
key = "fewer-doors:#{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["verdict"], reply["shortlist"]].inspect
$key = "fewer-doors:" . $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["verdict"] ?? ""), " ", implode(",", $reply["shortlist"] ?? []), "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"fewer-doors:{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!["verdict"]} {reply["shortlist"]?.ToJsonString()}");
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
rev = json.load(open("input.json")) # the same run input as input.json: {"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
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "fewer-doors:trim:" + 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": `fewer-doors:trim:${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", "fewer-doors:trim:"+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", "fewer-doors:trim:" + 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" => "fewer-doors:trim:#{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: fewer-doors:trim:" . 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", $"fewer-doors:trim:{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);