← How Many Balloons?
API

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.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field (task, things, page_facts) is missing.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: a weigh-in is metered and needs a personal token.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field 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.

FieldRequiredWhat it holds
taskyesAlways weigh.
thingsyesA 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_factsyesThe page's notes, one key | ... per line: count, your_weights, living_hint (a hint only) and verdict_rule.
clip_notenoWhat the page cut, if anything.
retry_notenoOnly 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": "..."}
FieldWhat it holds
itemsOne 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_reasonThe 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_downThe 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 / titleWhat 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"}
}

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')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}' 

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "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}' 

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}' 

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