Drive How Many Balloons from your own code
The one metered task, weigh, is available over HTTP. It takes a list of things to float (a name, the person's own weight in grams when they have one, and a note per thing) and the page's own notes. It returns a low, likely and high weight in grams for every thing nobody weighed, each anchored on a base rate, your own weights unchanged, anyone alive kept on the ground, the one thing worth weighing first, a verdict and a short flight note.
The division of labour first: the page owns the parsing, the unit conversion, the people-and-animals word list, the balloon lift table and every balloon count; the model owns the weight estimates, the base rates and the wording; the page then re-checks the reply. The model never writes a balloon count.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"how-many-balloons"}), 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, things, page_facts) 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: a weigh-in is metered and needs a personal token. |
| 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 and the verdicts
Every run sends task: weigh (the one task is weigh); a missing or different one is still answered as weigh. The verdict is a rule on the likely total of everything that is not grounded, checked in this order: grounded when every thing is alive, bouquet at 100 g or less, armful at 2000 g or less, cloud above that.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always weigh. |
things | yes | A JSON-encoded array (a string) of up to 12 things: {"id":"t1","name":"...","weight_g":"","note":"..."}. weight_g is the person's own weight in grams, or "". |
page_facts | yes | The page's notes, one key | ... per line: count, your_weights, living_hint (a hint only) and verdict_rule. |
clip_note | no | What the page cut, if anything. |
retry_note | no | Only on the automatic reformat retry. |
The reply
One JSON object, in output.output of the finished job:
{"task": "weigh", "title": "...", "verdict": "bouquet | armful | cloud | grounded", "likely_total_g": 0, "verdict_reason": "...",
"items": [{"id": "t1", "grounded": false, "low_g": 0, "likely_g": 0, "high_g": 0, "basis": "..."}],
"pin_down": {"id": "t1", "how": "..."}, "flight_note": "..."}
| Field | What it holds |
|---|---|
items | One entry per thing, in list order. Grounded (a person or a live animal): weights null. Weighed: low, likely and high all equal the person's weight. Estimated: whole grams, low < likely < high, and a basis (at most 20 words) naming the base rate and the assumption that widens the range most. |
likely_total_g / verdict / verdict_reason | The sum of the likely weights that are not grounded, the rule above, and one sentence (at most 30 words) quoting the total in grams. |
pin_down | The estimated thing with the widest range, and one way to pin its weight down at home (at most 25 words); empty when nothing was estimated. |
flight_note / title | What floating the list would look like (at most 45 words, no balloon counts); a playful title (at most 8 words). |
The page then checks the reply (LiftKit.check): one answer per thing, your weights kept exactly, ranges in order and not flat, the verdict against the rule on the reply's own weights, the total, the widest range picked to weigh first, word limits, and no balloon count, weekday, month, season, he or she, or internal field name in the text. Balloon counts come from LiftKit.counts(totals, kind): the light case over the strongest balloon, the likely case over the typical one, the heavy case over the weakest.
Worked examples, one per verdict
These are the four free samples on the page: the exact input and a saved reply made with the app's prompt, unedited. Together they cover bouquet, armful, cloud and grounded, weighed and estimated things, a toy that only looks like an animal, and a hamster that stays on the ground.
Four light things, one weighed - bouquet
Input:
{
"task": "weigh",
"things": "[{\"id\":\"t1\",\"name\":\"a paper party crown\",\"weight_g\":\"\",\"note\":\"\"},{\"id\":\"t2\",\"name\":\"a birthday card\",\"weight_g\":\"\",\"note\":\"in its envelope\"},{\"id\":\"t3\",\"name\":\"a ping-pong ball\",\"weight_g\":\"3\",\"note\":\"\"},{\"id\":\"t4\",\"name\":\"a teabag\",\"weight_g\":\"\",\"note\":\"\"}]",
"page_facts": "count | 4 things\nyour_weights | t3 a ping-pong ball = 3 g\nliving_hint | none\nverdict_rule | grounded when every item is grounded; bouquet when the likely total of the other items is 100 g or less; armful when it is 2000 g or less; cloud above 2000 g"
}
Reply:
{
"task": "weigh",
"title": "Crown, Card, Ball and Teabag Take Flight",
"verdict": "bouquet",
"likely_total_g": 40,
"verdict_reason": "Together these come to 40 g, light enough to drift as a gentle bouquet.",
"items": [
{
"id": "t1",
"grounded": false,
"low_g": 5,
"likely_g": 10,
"high_g": 20,
"basis": "A paper crown is light paper: 5-20 g; wider if it has extra trim."
},
{
"id": "t2",
"grounded": false,
"low_g": 15,
"likely_g": 25,
"high_g": 45,
"basis": "A card with its envelope is ordinary paper: typically 15-45 g; wider if the envelope is large."
},
{
"id": "t3",
"grounded": false,
"low_g": 3,
"likely_g": 3,
"high_g": 3,
"basis": "Your own weight."
},
{
"id": "t4",
"grounded": false,
"low_g": 1,
"likely_g": 2,
"high_g": 4,
"basis": "A teabag is nearly weightless: about 2 g dry, a little more with a tag and string."
}
],
"pin_down": {
"id": "t2",
"how": "Weigh it with a kitchen scale, envelope and all, to swap this guess for its exact weight."
},
"flight_note": "Picture a paper crown, a card tucked in its envelope, a steady ping-pong ball and a feather-light teabag, lifting together as one cheerful little cluster, light enough to float just above your fingertips."
}
A gnome, a toy, a paperback and a hamster - armful
Input:
{
"task": "weigh",
"things": "[{\"id\":\"t1\",\"name\":\"a garden gnome\",\"weight_g\":\"900\",\"note\":\"the chipped one\"},{\"id\":\"t2\",\"name\":\"a stuffed toy dog\",\"weight_g\":\"\",\"note\":\"small enough for a coat pocket\"},{\"id\":\"t3\",\"name\":\"a paperback novel\",\"weight_g\":\"\",\"note\":\"about 300 pages\"},{\"id\":\"t4\",\"name\":\"my hamster\",\"weight_g\":\"\",\"note\":\"\"}]",
"page_facts": "count | 4 things\nyour_weights | t1 a garden gnome = 900 g\nliving_hint | t4 my hamster (word: hamster)\nverdict_rule | grounded when every item is grounded; bouquet when the likely total of the other items is 100 g or less; armful when it is 2000 g or less; cloud above 2000 g"
}
Reply:
{
"task": "weigh",
"title": "Gnome, Toy Dog, Novel and One Grounded Hamster",
"verdict": "armful",
"likely_total_g": 1300,
"verdict_reason": "Together your things add up to 1300 g, an armful you could easily hold.",
"items": [
{
"id": "t1",
"grounded": false,
"low_g": 900,
"likely_g": 900,
"high_g": 900,
"basis": "Your own weight."
},
{
"id": "t2",
"grounded": false,
"low_g": 60,
"likely_g": 100,
"high_g": 150,
"basis": "A small plush toy that size usually weighs 60-150 g, depending on how densely it's stuffed."
},
{
"id": "t3",
"grounded": false,
"low_g": 240,
"likely_g": 300,
"high_g": 360,
"basis": "A typical paperback is 200-350 g, assuming a standard trim size for that page count."
},
{
"id": "t4",
"grounded": true,
"low_g": null,
"likely_g": null,
"high_g": null,
"basis": "Hamsters are alive, so it stays on the ground - no estimate needed, like any living creature."
}
],
"pin_down": {
"id": "t3",
"how": "Weigh it on a kitchen scale - the exact page count and paper weight pin down a precise number fast."
},
"flight_note": "Picture the gnome, the pocket-sized toy dog and the paperback drifting upward together, while your hamster - alive and grounded - stays firmly on the ground the whole time."
}
Furniture and a bicycle - cloud
Input:
{
"task": "weigh",
"things": "[{\"id\":\"t1\",\"name\":\"a two-seat sofa\",\"weight_g\":\"\",\"note\":\"\"},{\"id\":\"t2\",\"name\":\"a bicycle\",\"weight_g\":\"\",\"note\":\"an old steel one\"},{\"id\":\"t3\",\"name\":\"a lawn chair\",\"weight_g\":\"\",\"note\":\"the folding aluminium kind\"}]",
"page_facts": "count | 3 things\nyour_weights | none\nliving_hint | none\nverdict_rule | grounded when every item is grounded; bouquet when the likely total of the other items is 100 g or less; armful when it is 2000 g or less; cloud above 2000 g"
}
Reply:
{
"task": "weigh",
"title": "Sofa, Bicycle and Lawn Chair Take Flight",
"verdict": "cloud",
"likely_total_g": 63000,
"verdict_reason": "Floating all three would take a lot of lift: the likely total is 63000 g.",
"items": [
{
"id": "t1",
"grounded": false,
"low_g": 30000,
"likely_g": 45000,
"high_g": 65000,
"basis": "A two-seat sofa typically weighs 30-70 kg; heavier if the frame is solid wood."
},
{
"id": "t2",
"grounded": false,
"low_g": 11000,
"likely_g": 15000,
"high_g": 18000,
"basis": "Steel bicycles typically weigh 12-18 kg; heavier if the frame is a larger size."
},
{
"id": "t3",
"grounded": false,
"low_g": 2000,
"likely_g": 3000,
"high_g": 4000,
"basis": "Folding aluminium chairs typically weigh 2-4 kg; lighter if the frame tubing is thinner."
}
],
"pin_down": {
"id": "t1",
"how": "Check the manufacturer's tag or manual for its listed weight - that fixes the biggest range here without lifting it at all."
},
"flight_note": "Picture a sofa, an old bicycle and a folding lawn chair all tugging gently upward together, each one trying its best to drift free from the ground."
}
Everyone is alive - grounded
Input:
{
"task": "weigh",
"things": "[{\"id\":\"t1\",\"name\":\"me\",\"weight_g\":\"\",\"note\":\"\"},{\"id\":\"t2\",\"name\":\"my cat\",\"weight_g\":\"\",\"note\":\"very round\"}]",
"page_facts": "count | 2 things\nyour_weights | none\nliving_hint | t1 me (word: me); t2 my cat (word: cat)\nverdict_rule | grounded when every item is grounded; bouquet when the likely total of the other items is 100 g or less; armful when it is 2000 g or less; cloud above 2000 g"
}
Reply:
{
"task": "weigh",
"title": "You and Your Round Cat, Staying Grounded",
"verdict": "grounded",
"likely_total_g": 0,
"verdict_reason": "Nothing on this list can go up - everything here is alive and stays on the ground.",
"items": [
{
"id": "t1",
"grounded": true,
"low_g": null,
"likely_g": null,
"high_g": null,
"basis": "You're alive, so you stay on the ground in this thought experiment."
},
{
"id": "t2",
"grounded": true,
"low_g": null,
"likely_g": null,
"high_g": null,
"basis": "It's alive, so it stays on the ground too, round or not."
}
],
"pin_down": {
"id": "",
"how": ""
},
"flight_note": "In this thought experiment, you and your round cat simply stay on the ground - some things just aren't meant to float, and that's perfectly fine."
}
Build the input yourself
Load the script in node (global.window = global; require("./liftkit.js")), then:
const K = LiftKit;
const built = K.buildInput({ balloon: "latex11", things: "a teabag\na garden gnome | 900 g\nmy cat" });
built.things.map(t => t.hint); // ["", "", "cat"] - the cat stays on the ground
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes // the page's re-check of the weigh-in
// const t = K.totals(K.rows(built, reply)); // low, likely and high grams
// K.counts(t, "latex11") // { low, likely, high } balloons
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://how-many-balloons.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://how-many-balloons.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"]
// how-many-balloons.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://how-many-balloons.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://how-many-balloons.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
}
// HowManyBalloons.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 HowManyBalloons {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://how-many-balloons.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://how-many-balloons.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://how-many-balloons.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://how-many-balloons.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":"how-many-balloons"}' | 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": "how-many-balloons"})["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: "how-many-balloons" })).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": "how-many-balloons"}, 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", "how-many-balloons"), 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: "how-many-balloons" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "how-many-balloons"])["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 = "how-many-balloons" }))!["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": "weigh", "things": "[{\"id\":\"t1\",\"name\":\"a garden gnome\",\"weight_g\":\"900\",\"note\":\"the chipped one\"},{\"id\":\"t2\",\"name\":\"a stuffed toy dog\",\"weight_g\":\"\",\"note\":\"small enough for a coat pocket\"},{\"id\":\"t3\",\"name\":\"a paperback novel\",\"weight_g\":\"\",\"note\":\"about 300 pages\"},{\"id\":\"t4\",\"name\":\"my hamster\",\"weight_g\":\"\",\"note\":\"\"}]", "page_facts": "count | 4 things\nyour_weights | t1 a garden gnome = 900 g\nliving_hint | t4 my hamster (word: hamster)\nverdict_rule | grounded when every item is grounded; bouquet when the likely total of the other items is 100 g or less; armful when it is 2000 g or less; cloud above 2000 g"}
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": "weigh", "things": "[{\"id\":\"t1\",\"name\":\"a garden gnome\",\"weight_g\":\"900\",\"note\":\"the chipped one\"},{\"id\":\"t2\",\"name\":\"a stuffed toy dog\",\"weight_g\":\"\",\"note\":\"small enough for a coat pocket\"},{\"id\":\"t3\",\"name\":\"a paperback novel\",\"weight_g\":\"\",\"note\":\"about 300 pages\"},{\"id\":\"t4\",\"name\":\"my hamster\",\"weight_g\":\"\",\"note\":\"\"}]", "page_facts": "count | 4 things\nyour_weights | t1 a garden gnome = 900 g\nliving_hint | t4 my hamster (word: hamster)\nverdict_rule | grounded when every item is grounded; bouquet when the likely total of the other items is 100 g or less; armful when it is 2000 g or less; cloud above 2000 g"}
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: "weigh", things, page_facts} (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 how-many-balloons:<task>:<inputHash>:a<attempt> (the one task is weigh), 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="how-many-balloons:weigh:$(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 = "how-many-balloons:" + 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("title"))
import { createHash } from "node:crypto";
const key = `how-many-balloons:${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.title);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "how-many-balloons:" + 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 = "how-many-balloons:" + 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 = "how-many-balloons:#{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["title"]].inspect
$key = "how-many-balloons:" . $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["title"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"how-many-balloons:{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: how-many-balloons:weigh:$(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": "weigh", "things": "[{\"id\":\"t1\",\"name\":\"a garden gnome\",\"weight_g\":\"900\",\"note\":\"the chipped one\"},{\"id\":\"t2\",\"name\":\"a stuffed toy dog\",\"weight_g\":\"\",\"note\":\"small enough for a coat pocket\"},{\"id\":\"t3\",\"name\":\"a paperback novel\",\"weight_g\":\"\",\"note\":\"about 300 pages\"},{\"id\":\"t4\",\"name\":\"my hamster\",\"weight_g\":\"\",\"note\":\"\"}]", "page_facts": "count | 4 things\nyour_weights | t1 a garden gnome = 900 g\nliving_hint | t4 my hamster (word: hamster)\nverdict_rule | grounded when every item is grounded; bouquet when the likely total of the other items is 100 g or less; armful when it is 2000 g or less; cloud above 2000 g"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "how-many-balloons:weigh:" + 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": `how-many-balloons:weigh:${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", "how-many-balloons:weigh:"+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", "how-many-balloons:weigh:" + 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" => "how-many-balloons:weigh:#{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: how-many-balloons:weigh:" . 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", $"how-many-balloons:weigh:{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);