Drive Balance Desk from your own code
Balance Desk takes one game balance table — the rows of items, units, upgrades, spells, shop
entries or enemy waves that live in a designer's spreadsheet — and works it through one of three
lanes: an audit of the power curve, an economy pass over the
prices, the sinks, the grind and the drop table, or a playtest reconciliation that
settles a session's complaints against the table's own arithmetic. Everything the web app does is
one HTTP API away. Base URL: https://api.skillsafe.ai/v1/app-api, scoped to this app
by the token you send. Every response is wrapped in an envelope: success is
{"ok":true,"data":{…}}, failure is
{"ok":false,"error":{"code":"…","message":"…","details":{…}}}.
Read the envelope, not the HTTP status, for anything the app itself decided: a job that ran and
then failed still arrives inside {"ok":true,"data":{…}} with a terminal
status. The status line matters for the transport errors — 401, 402, 403, 429.
The request body for /run, /run-stream and /estimate
is the input object itself. There is no input wrapper and no
X-App-Slug header — the slug is carried by the token. This is the single most common
way to waste a credit here, so it is spelled out again in
the input contract.
Error codes you will actually meet
| Code | HTTP | What it means | What to do |
|---|---|---|---|
UNAUTHORIZED | 401 | Missing, malformed or expired token. | Mint a new one from the token panel. |
FORBIDDEN | 403 | The token belongs to another app, or it is a guest token on a metered route. | Use an account token minted for balance-desk. Guests cannot run. |
INSUFFICIENT_CREDITS | 402 | Balance is under min_credits for this run. | Compare /me against /estimate before running. |
VALIDATION_ERROR | 400 | The input object is the wrong shape. | Check error.details; task and table are required. |
RATE_LIMITED | 429 | Too many requests. | Back off; do not tight-loop a poll. |
NOT_FOUND | 404 | No such route, or no such job_id for this subject. | Check the path and that the job was created by this token. |
INTERNAL | 500 | Something broke on our side. | Retry with the same idempotency key. It will not double-bill. |
A guest token can look, not run. POST /v1/app-api/guest mints an
anonymous subject that can call /me and /estimate — enough to price a
lane and show a caller what the contract is. A /run or /run-stream is
metered, and this app does not sponsor guest usage, so a guest run comes back
403 FORBIDDEN. Use a personal token from the token panel for anything that produces a
review. The in-browser arithmetic — the parse, the power table, the dominance comparison, the
price fit, the drop odds and the BX flags — is free and needs no token at all.
VALIDATION_ERROR is worth reading rather than retrying. Its details names
the field: a missing task, a missing or empty table, a facts
that arrived as a string where an object was expected. None of those cost a credit, because
validation happens before the job is created.
The task field comes first
This app has three lanes over one work object: a single balance table.
task selects the lane and is the field to get right before any other — it decides the
checks you get back, which lane block is present, which verdict tokens are legal, and the price. An
unrecognised value is not an error: the model picks the closest lane, names that choice in
overview, and sets lane to what it picked. It never blends two lanes'
contracts into one object. So always read lane back rather than assuming the one you
asked for.
task | Lane | Answers | Lane block | Source skill |
|---|---|---|---|---|
audit | Audit | What is wrong with this table before players find it? | audit{} | @donchitos/balance-check |
economy | Economy | Does the money work — the ladder, the sinks, the grind, the drop table? | economy{} | @thedivergentai/godot-economy-system |
playtest | Playtest | Which of the session's complaints do the numbers actually support? | playtest{} | @donchitos/playtest-report |
Note the collision worth knowing about up front: each lane block is named after its own
lane. An audit run returns an audit key, an
economy run returns an economy key, a playtest run returns a
playtest key. Exactly one of the three is present; the other two are absent, not null.
One worked example per lane
Each request body below is the whole body — copy the shape, not just the fields. The
table value is elided here; in a real call it is the whole table as one JSON string,
newlines and all. The facts object is abridged to its flags for readability. Only
task and table are required; every other field may be omitted or sent
empty.
task: "audit" — what is wrong with this table?
Request body:
{"task":"audit",
"table":"name,tier,cost,damage,attack_speed,crit_chance,range,cooldown\nRusted Dagger,1,60,8,1.6,5,1.2,0.5\nHunting Bow,1,90,11,1.1,7,6.0,0.9\n…\nVoid Blade,3,900,52,1.3,22,1.8,0.8",
"genre":"roguelike",
"intent":"Tier 1 and 2 costs ship next week and are locked. Tier 3 is still open.",
"notes":"",
"carryover":"",
"facts":{"readable":true,"format":"csv","rows":8,"columns":["name","tier","cost","damage","attack_speed","crit_chance","range","cooldown"],
"roles":{"name":"name","tier":"tier","cost":"cost","rarity":null,"weight":null,"income":null,
"stats":["damage","attack_speed","crit_chance","range","cooldown"],"lower_is_better":["cooldown"],"ignored":[]},
"summary":{"entryCount":8,"statCount":5,"tierCount":3,"hasCost":true,"medianPower":0.4128,"strongest":"Void Blade","weakest":"Rusted Dagger"},
"flags":[{"id":"BX-001","level":"high","code":"strictly_dominated","title":"\"Bone Reaper\" is strictly dominated",
"detail":"Bone Reaper is matched or beaten on every stat by 1 other entry (Void Blade) at the same cost or less."},
{"id":"BX-002","level":"high","code":"dominant_pick","title":"\"Void Blade\" dominates 2 other entries",
"detail":"It is at least as good on every stat as 2 peers at the same cost or less."},
{"id":"BX-003","level":"medium","code":"overpriced","title":"\"Warhammer\" is overpriced for its power",
"detail":"It costs 310 where the fit predicts 224 (z = 1.71)."}]}}
The reply is the shared envelope plus this lane's block:
{ …envelope…, "verdict": "tunable",
"audit": {"power_read": "Power runs 0.1042 to 0.8375 with the mass between 0.30 and 0.50; tier 3 holds the top three places and the gap from tier 2 is wider than either step below it.",
"dead_content": [{"entry": "Bone Reaper", "why": "Strictly dominated by Void Blade: lower damage, lower crit, longer cooldown, and it costs 880 against 900.",
"fix": "Give it the only cleave in the table, or drop its cost to 640 so it undercuts the tier."}],
"auto_includes": [{"entry": "Void Blade", "why": "Power 0.8375 against a table median of 0.4128, and it dominates two tier-3 peers at equal cost.",
"fix": "crit_chance 22 to 14 brings power to about 0.72 and leaves Ember Wand a reason to exist."}],
"curve": {"shape": "geometric", "reads_as": "Each tier feels like a step change rather than a refinement.",
"target": "A roguelike wants a flatter curve than this so a tier-1 pick stays viable in a run; roughly 1.4x per tier, not 2.1x."},
"retune": [{"entry": "Void Blade", "field": "crit_chance", "from": "22", "to": "14",
"reason": "The fit predicts 640 for power 0.8375; costs are open at tier 3 but the stat move is cheaper than the reprice."}],
"note": "This audit reads the numbers only. It cannot tell you whether the Void Blade's animation is what makes it feel mandatory."} }
task: "economy" — does the money work?
Request body:
{"task":"economy",
"table":"name,tier,rarity,cost,drop_weight,gold_reward,damage,armour\nScrap Knife,1,common,60,42,0,8,0\nPatched Vest,1,common,80,38,0,0,6\n…\nSunken Crown,4,legendary,9600,0.4,0,0,44",
"genre":"arpg",
"intent":"The shop and the loot table share one currency. I want to know whether the top tier is reachable without an in-app purchase.",
"notes":"",
"carryover":"Previous lane: audit. Verdict: tunable. Tier 4 is one entry and it carries the whole top of the curve.",
"facts":{"readable":true,"format":"csv","rows":14,
"roles":{"name":"name","tier":"tier","cost":"cost","rarity":"rarity","weight":"drop_weight","income":"gold_reward",
"stats":["damage","armour"],"lower_is_better":[],"ignored":[]},
"economy":{"sinkCount":14,"faucetCount":6,"sinkTotal":31840,"faucetTotal":610,"sinkFaucetRatio":52.197,
"costMin":60,"costMax":9600,"costMedian":420,"costSpread":160,"grindCount":6,"grindMedian":14},
"drops":{"scale":"percent","total":100,"rows":[{"name":"Scrap Knife","weight":42,"p":0.42,"percent":42,"pulls50":2,"pulls90":5},
{"name":"Sunken Crown","weight":0.4,"p":0.004,"percent":0.4,"pulls50":173,"pulls90":575}],
"issues":[{"kind":"long_tail","detail":"\"Sunken Crown\" needs 575 pulls for a 90% chance at one"}]},
"flags":[{"id":"BX-001","level":"medium","code":"drop_long_tail","title":"One entry sits behind a very long tail",
"detail":"\"Sunken Crown\" needs 575 pulls for a 90% chance at one."},
{"id":"BX-002","level":"high","code":"drop_rarity_inversion","title":"A rarity label contradicts its drop rate",
"detail":"\"Ashen Mail\" (epic) drops more often than \"Tidecaller Ring\" (rare)."},
{"id":"BX-003","level":"medium","code":"grind_wall","title":"\"Sunken Crown\" is a grind wall",
"detail":"It takes 320 income units to afford, against a table median of 14."}]}}
The reply is the shared envelope plus this lane's block:
{ …envelope…, "verdict": "grindy",
"economy": {"currency_read": "One soft currency does every job here: it buys the shop rows and it is paid out by six of the fourteen entries. Sinks total 31840 against faucets of 610, a ratio of 52.2 to 1 measured across the table as written.",
"ladder": [{"step": "tier 1", "cost_range": "60-120", "power_range": "0.08-0.19", "reads_as": "An hour-one purchase you make without thinking."},
{"step": "tier 4", "cost_range": "9600-9600", "power_range": "0.94-0.94", "reads_as": "A single row priced 22x the tier below it."}],
"sinks": [{"entry": "Sunken Crown", "cost": "9600", "role": "progression"},
{"entry": "Scrap Knife", "cost": "60", "role": "catch-up"}],
"faucets": [{"source": "gold_reward on the six wave rows", "amount": "610 across the table", "note": "Per-clear rate is not stated, so this is a total, not a rate."}],
"grind": {"worst": "Sunken Crown", "units": "320 clears of a wave that pays 30",
"verdict": "Against a table median of 14 units, that is a different kind of ask and it reads as a monetisation gate rather than a goal."},
"drop_table": {"read": "Weights are percentages and total 100 exactly. Sunken Crown at 0.4% needs 173 pulls for an even chance and 575 for a 90% one; Ashen Mail at epic drops more often than Tidecaller Ring at rare, so the labels do not describe the odds.",
"fixes": [{"entry": "Ashen Mail", "from": "3.1", "to": "1.6", "reason": "Puts the epic below the rare it currently outdrops."},
{"entry": "Sunken Crown", "from": "0.4", "to": "1.9", "reason": "Brings a 90% chance inside 120 pulls; the 1.5 comes off Ashen Mail so the column still sums to 100."}]},
"reprice": [{"entry": "Sunken Crown", "from": "9600", "to": "4200", "reason": "The fit predicts 4180 at power 0.94; 9600 sits 2.6 residual standard deviations above the line."}],
"note": "The table carries no per-session income rate and no premium currency, so nothing here can price the crown in real time or in real money."} }
task: "playtest" — which complaints do the numbers support?
Request body:
{"task":"playtest",
"table":"name,tier,cost,damage,attack_speed,crit_chance,range,cooldown\nRusted Dagger,1,60,8,1.6,5,1.2,0.5\n…\nVoid Blade,3,900,52,1.3,22,1.8,0.8",
"genre":"roguelike",
"intent":"",
"notes":"Build 0.7.2, four testers, 90 minutes.\n- Everyone bought the Void Blade the moment they could afford it. Two of them said the run was over at that point.\n- Two testers said the Warhammer felt bad but could not say why.\n- One tester wanted the Hunting Bow to hit harder at range.\n- Nobody used the shield rune.",
"carryover":"Previous lane: audit. Verdict: tunable. Void Blade is the auto-include; Bone Reaper is dominated.",
"facts":{"readable":true,"format":"csv","rows":8,
"power_table":[{"name":"Void Blade","tier":3,"cost":900,"power":0.8375,"efficiency":0.00093,"efficiency_z":2.14},
{"name":"Warhammer","tier":2,"cost":310,"power":0.3416,"efficiency":0.0011,"efficiency_z":-0.42}],
"flags":[{"id":"BX-001","level":"high","code":"dominant_pick","title":"\"Void Blade\" dominates 2 other entries",
"detail":"It is at least as good on every stat as 2 peers at the same cost or less."},
{"id":"BX-002","level":"medium","code":"overpriced","title":"\"Warhammer\" is overpriced for its power",
"detail":"It costs 310 where the fit predicts 224 (z = 1.71)."},
{"id":"BX-003","level":"high","code":"strictly_dominated","title":"\"Bone Reaper\" is strictly dominated",
"detail":"Bone Reaper is matched or beaten on every stat by 1 other entry (Void Blade) at the same cost or less."}]}}
The reply is the shared envelope plus this lane's block:
{ …envelope…, "verdict": "partly confirmed",
"playtest": {"session": {"testers": "four", "build": "0.7.2", "focus": "A 90-minute open session over the tier 1-3 weapon shop, with no stated brief."},
"claims": [{"claim": "Everyone bought the Void Blade as soon as they could afford it and the run was over at that point.",
"entries": ["Void Blade"], "support": "supported",
"evidence": "Power 0.8375 against a table median of 0.4128, efficiency z = 2.14, and it dominates two tier-3 peers at equal cost.",
"action": "crit_chance 22 to 14."},
{"claim": "The Warhammer felt bad, with no reason given.",
"entries": ["Warhammer"], "support": "supported",
"evidence": "It costs 310 where the cost-on-power fit predicts 224 (z = 1.71) — the table agrees it is a bad buy even though nobody named the price.",
"action": "cost 310 to 240."},
{"claim": "The Hunting Bow should hit harder at range.",
"entries": ["Hunting Bow"], "support": "unsupported",
"evidence": "range is a flat column with no falloff term, so the table cannot express damage at distance either way.",
"action": "watch"},
{"claim": "Nobody used the shield rune.",
"entries": [], "support": "no data",
"evidence": "There is no row named shield rune in this table.",
"action": "watch"}],
"unheard": [{"entry": "Bone Reaper", "why": "It is strictly dominated by Void Blade and nobody in the session mentioned it, which is what being dead content looks like from inside a test."}],
"build_notes": [{"priority": "P1", "change": "Void Blade crit_chance 22 to 14.", "expected": "The tier-3 pick becomes a choice between three weapons rather than one."},
{"priority": "P2", "change": "Warhammer cost 310 to 240.", "expected": "Testers stop skipping tier 2 on their way to the top tier."}],
"note": "A 90-minute session with four testers cannot settle whether the Void Blade is mandatory or merely first; a run where it is unavailable would."} }
The input contract
These are the exact fields the web app submits — taken from its run path, not from intent.
{
"task": "audit",
"table": "<the balance table, as a STRING — required>",
"genre": "roguelike",
"intent": "free text: what the table is for, what is suspected wrong, what is locked",
"notes": "<raw playtest notes; the playtest lane reads these>",
"carryover": "optional: the previous lane's digest",
"facts": { "readable": true, "…": "the measured facts OBJECT, with its BX-nnn flags" }
}
That object is the request body. Do not wrap it. A body of
{"input":{"task":"audit",…}} is accepted, returns 200, creates a job
and bills it — and the model never sees task or table, because the
fields it was told to read are one level deeper than it looks. The reply comes back as a review of
nothing. There is no X-App-Slug header either; the token names the app.
| Field | Type | Required | Notes |
|---|---|---|---|
task | string | yes | One of exactly three lane ids: audit, economy, playtest. Document it first because it decides everything else — the checks, the lane block, the verdict tokens and the price. |
table | string | yes | The balance table as one string: CSV, TSV, semicolon- or pipe-delimited text, a markdown table pasted out of a design document, a JSON array of entry objects, or a JSON object keyed by entry name. This is the work object; every lane operates on this one artifact. Clipped at about 26 000 characters — see below. |
genre | string | no | roguelike, arpg, deckbuilder, tower-defence, idle, 4x, strategy, mmo, shooter, platformer, or "". It sets what curve shape the table should have: a roguelike wants a flatter one than an ARPG. Empty means the lane says what it assumed. |
intent | string | no | Free text from the designer: what this table is meant to do, what they suspect is wrong, and — most valuably — what they are not allowed to change. "Tier 1 and 2 costs ship next week" is the single cheapest way to stop a lane proposing a retune you cannot ship. |
notes | string | no | Raw playtest notes: who tested, which build, and what they said. Read by the playtest lane only; the other two ignore it. Every distinct point in it becomes exactly one claims entry, in the order the notes make them. |
carryover | string | no | A digest of the previous lane's conclusion, when you are running more than one lane over the same table. Treated as the designer's own words rather than as measured fact: the lane builds on it, acknowledges it where it changed the reading, and — if it contradicts facts — says where they disagree and follows facts. |
facts | object | no | The deterministic reader's output — a JSON object, not a string and not an array. Optional, strongly recommended; see below. |
Every optional string field may be sent as "" or left out entirely; the two are
equivalent. facts may be omitted or sent as null. What you must not do is
send facts as a string — this app's facts is structured, unlike some other
desks on SkillSafe, and a string there is a VALIDATION_ERROR.
The three lane ids
audit— the designer wants to know what is wrong with the table before players find it. Reads the power distribution, names the dead content and the auto-includes with the measurement that proves each one, judges the tier curve against the genre, and hands back a retune list of concrete numbers. It does not price anything and it does not readnotes.economy— the designer wants to know whether the money works: whether the prices ladder, whether the sinks and faucets are proportionate, how long the grind is in the table's own income units, and whether the drop table delivers what its rarity labels claim. Needs a cost, income or weight column to say anything; without one the honest verdict isunreadable.playtest— the designer has just run a session and has raw notes. Turns every point into a claim and settles each one against the table's own arithmetic assupported,contradicted,unsupportedorno data— and then lists the problems the table raises that nobody reported. The only lane that readsnotes; the table is still the work object.
An unrecognised task is answered by the closest lane rather than rejected: an
intent about prices or the grind gets economy, a non-empty
notes gets playtest, anything else gets audit. The lane it
chose is named in lane and explained in overview. It will not blend two
lanes' contracts — you always get one lane block and only that lane's checks.
Clipping table yourself
table is capped at about 26 000 characters. Over that, the app clips
on whole-row boundaries, always keeps the header row, and appends a marker in-band —
... Balance Desk sent N of T rows; the rest were cut on row boundaries and the measured facts
below cover all of them. — so the model knows the table it is reading is a slice and can say
so. A caller driving this API should do the same rather than a bare truncation.
Two reasons this matters more here than it looks. First, the header row is the whole
contract: cut it and every column role is lost, entries become row numbers, and the reply
is a review of anonymous rows. Second, facts is computed over the whole
table even when the text is clipped — that is what the marker's last clause is telling the
model. If you clip the text but measure the full table, say so; the lane will then quote a number
for a row it cannot see rather than pretending the row does not exist. If you clip
both, say that instead, because a power score over half a table is a different number.
Cut from the middle, not the end. A balance table's interesting rows are usually the cheapest and the most expensive, and both a naive head-clip and a naive tail-clip delete one of them.
facts: give the model ground truth
facts is a JSON object produced by a parser rather than a model. The web app fills it
from balancescan.js, which runs entirely in the browser and costs nothing: it reads
the paste as a table, detects which column holds the name, the tier, the cost, the rarity, the drop
weight and the income, treats every other numeric column as a stat, min-max normalises each stat in
its own "more is better" direction and averages them into a power score in 0..1,
divides power by cost to get efficiency, runs a full Pareto
dominance comparison, buckets entries by tier and measures the tier-over-tier power
ratio, fits an ordinary least-squares line of cost on power and reports R-squared and every entry's
residual, turns drop weights into probabilities and into the pulls needed for a 50% and a 90%
chance, and totals currency sinks against faucets. Every problem it found becomes a flag with a
BX-nnn id.
These are its real top-level keys. Send as many as you can compute; send none rather than guess.
| Key | Type | What it carries |
|---|---|---|
readable | boolean | Whether the parser could read the paste as a table at all. false collapses the object to {readable, note, flags} and changes how the lane behaves — see below. |
format | string | What it was read as: csv, tsv, delimited, markdown, json or unknown. |
rows | number | How many data rows were parsed, header excluded. |
columns | string[] | The header names exactly as they appeared. The lane quotes these, so send them verbatim rather than normalised. |
roles | object | Which column got which job. See the role table below. |
summary | object | entryCount, statCount, scoredStatCount, tierCount, hasCost, hasWeight, hasIncome, medianPower, strongest, weakest, ignoredColumns. The shape of the table in one object. |
stat_summary | object[] | One entry per stat column: stat, n, min, max, mean, median, sd, cv, gini, direction ("higher is better" or "lower is better") and outliers[{name, value, z}]. |
power_table | object[] | One entry per row: name, tier, cost, power, efficiency, efficiency_z. This is the spine of every lane's argument. |
dominance | object or null | dominated[{name, by[], count}], dominating[{name, dominates}] and comparable (how many entries had every stat populated). null when there is nothing to compare. |
progression | object or null | byTier[{tier, count, names, meanPower, meanCost}], shape (flat, linear, geometric, erratic, unknown), ratios[{from, to, ratio, delta}], medianRatio and issues[]. null when there is no tier column. |
pricing | object or null | The cost-on-power fit: slope, intercept, r2, n, plus underpriced[] and overpriced[], each row carrying name, power, cost, predicted, residual and z. null without a usable cost column. |
drops | object or null | scale (percent, unit or relative), total, rows[] (up to 30, each with name, weight, rarity, tier, p, percent, pulls50, pulls90) and issues[]. null without a drop-weight column. |
economy | object or null | sinkCount, faucetCount, sinkTotal, faucetTotal, sinkFaucetRatio, costMin, costMax, costMedian, costSpread, grind[{name, cost, income, units}], grindCount, grindMedian. null without a cost or an income column. |
flags | object[] | The list the review is graded against: each is {id: "BX-001", level, code, title, detail}. See the flag contract. |
facts.roles — which column does which job
| Key | Type | Meaning |
|---|---|---|
name | string or null | The column holding the entry name. Null means findings can only refer to row numbers, which is a flag in itself. |
tier | string or null | The column holding the tier, level, rank, wave or act. Drives the whole progression block. |
cost | string or null | The column holding the price. Drives efficiency, the pricing fit and the sink totals. |
rarity | string or null | The column holding a rarity or quality label. Checked against the drop odds for inversions. |
weight | string or null | The column holding a drop weight, rate, chance or probability. |
income | string or null | The column holding a payout, reward or yield. Without it there is no faucet number. |
stats | string[] | Every other numeric column. These are what power is averaged over. |
lower_is_better | string[] | The subset of stats where a smaller number is better — cooldown, cast time, reload, weight, recovery. This is the most load-bearing array in the object. Reading one of these backwards is the single most common way this analysis goes wrong, and if you compute your own facts it is the field to get right first. |
ignored | string[] | Columns that were neither a role nor usefully numeric — free-text descriptions, flavour, internal ids. Named so the lane can say what it did not read. |
The flags array is the contract
Each flag is an object with five fields:
{"id": "BX-003",
"level": "critical | high | medium | low | info",
"code": "strictly_dominated",
"title": "\"Bone Reaper\" is strictly dominated",
"detail": "Bone Reaper is matched or beaten on every stat by 1 other entry (Void Blade) at the same cost or less."}
id runs BX-001, BX-002, … in the order the flags are listed,
which the browser sorts most severe first. code is a stable machine-readable slug —
parse_warning, no_name_column, no_stats,
single_stat, no_cost_column, duplicate_names,
missing_values, nonpositive_cost, stat_outlier,
strictly_dominated, dominant_pick, efficiency_spike,
trap_option, progression_regression, progression_dead_tier,
progression_spike, erratic_curve, weak_price_fit,
underpriced, overpriced, drop_rarity_inversion,
drop_long_tail, drop_unnormalised, grind_wall,
wide_cost_spread. If you generate your own flags, you may invent your own codes; the
ids are what the contract turns on.
Every BX-nnn flag you send comes back exactly once in
coverage_check, in the order you sent it — with handled: true
when the output addresses it, or handled: false and an honest reason when it does not.
That is the contract, and it is the cheapest way to tell a real review from a plausible one. The
model may not invent a BX id that was not in facts, and it may not
silently drop one that was. Assert both in your client.
The rest of facts is treated as true. Never expect the lane to contradict a
measured number: if facts says Void Blade has power 0.6917 and costs 900, the
reply quotes 0.6917 and 900, not "roughly 0.7 for about a thousand". Power and efficiency are the
reader's definitions and nothing else — power is the mean of min-max normalised stats, relative to
this table only, and it says nothing about how an entry feels in play. The lanes are
written to say so when it matters.
Calling without facts
You may omit facts entirely, and the run will succeed. It will be a weaker review, and
it is worth being precise about why. Without it the model has no measured ground
truth: there is no power score, so "dead content" and "auto-include" become impressions
rather than proofs; there is no dominance comparison, so a strictly dominated row is a claim rather
than an arithmetic fact; there is no cost-on-power fit, so a proposed reprice has no line to move
toward and the reason field on every retune loses its number. Worst of all, there is
no flags array — so coverage_check comes back
[], and with it goes the one part of this contract that is externally checkable. A
review with an empty coverage_check is a review nothing obliged to answer for
anything.
The flags are what coverage_check answers. That is the whole
relationship: facts.flags[].id in, coverage_check[].flag out, one for one,
in order. If you drive this API from a browser, run balancescan.js and send its
BalanceScan.facts(BalanceScan.scan(table)) output straight through. If you drive it
from a server, computing even a partial equivalent — the power table and a handful of flags — buys
more review quality per line of code than anything else on this page.
If your parser could not read the table at all, say so rather than sending nothing: set
{"readable": false, "note": "…", "flags": [...]}. The lane then states plainly in
overview that there were no measured facts, judges the paste on its own, and sets
every check that depended on measurement to n/a — which is a better answer than one
silently anchored to nothing.
The output contract
The model replies with one JSON object as the job's output.output string — no code
fence, no text before or after. The outer shape is identical in every lane, so one parser handles
all three:
{
"lane": "audit",
"table_name": "early-game roguelike weapon shop",
"verdict": "<one of the lane's verdict tokens>",
"headline": "one line, under 120 characters, the single most important thing",
"overview": "2 to 5 sentences: what this table is, and what this lane concluded",
"checks": [{"name": "<exactly the lane's check name, in the lane's order>",
"status": "pass | warn | fail | n/a",
"note": "one sentence"}],
"findings": [{"id": "BD-001",
"severity": "critical | high | medium | low",
"title": "short, specific",
"where": "an entry name or a column name",
"why": "why it matters, with the number that shows it",
"fix": "what to do",
"change": "field: from -> to, or \"\""}],
"coverage_check": [{"flag": "BX-003", "handled": true, "note": "how this lane addressed it"}],
"next_steps": ["one short imperative sentence, in the order they should be done"],
"<lane block>": "<exactly one, see the table below>"
}
lane echoes the task that was actually run, which is not always the one you sent —
route on it. table_name is three to six words naming what the table holds, and it is
what you should title a saved pass with rather than reusing your own filename.
findings ids run BD-001, BD-002, … in the order listed, most
severe first. Severity means something specific here: critical is the table is
unshippable as written — progression goes backwards, a cost is zero, a required column is missing;
high is it ships but a player finds the exploit or the dead option in the first
session; medium is a real imbalance that only shows up over hours; low is
tidiness, naming, a column that is not pulling its weight. Zero findings is a legitimate answer and
arrives as [], never as a finding that says there are none.
where is always populated — an entry name or a column name, never a vague location. A
finding without a where is a malformed reply. change is the concrete edit
in the form field: from -> to whenever a single number can express the fix, and
"" when it cannot; a client that renders a diff should read change and
fall back to fix only when it is empty.
checks carries every check name the lane lists, in the lane's order, even when
the answer is n/a — a short table is a bug, not brevity. A fail on any
check has a matching entry in findings, and a finding of severity
critical or high has a matching fail or warn
check: the two lists are two views of one judgement and they may not disagree. That invariant is
worth asserting in your client, because a reply where they diverge is a reply to distrust.
Empty is a value everywhere in this object. An array with nothing in it is [], never a
sentence explaining that there was nothing.
The lane block: exactly one, chosen by task
task | verdict tokens | Key | Shape |
|---|---|---|---|
audit |
balanced / tunable / broken |
audit |
Object: {power_read (string), dead_content[{entry, why, fix}], auto_includes[{entry, why, fix}], curve{shape, reads_as, target}, retune[{entry, field, from, to, reason}], note}. curve.shape is flat, linear, geometric, erratic or unknown. dead_content and auto_includes are grounded in dominance or efficiency, not taste; retune entries move the entry toward the fitted line in facts and say roughly where it lands. |
economy |
sustainable / inflationary / grindy / unreadable |
economy |
Object: {currency_read, ladder[{step, cost_range, power_range, reads_as}], sinks[{entry, cost, role}], faucets[{source, amount, note}], grind{worst, units, verdict}, drop_table{read, fixes[{entry, from, to, reason}]}, reprice[{entry, from, to, reason}], note}. sinks[].role is progression, cosmetic, consumable, catch-up or unknown. grind.units is in the table's own income units, never in minutes unless intent stated a rate. |
playtest |
confirmed / partly confirmed / contradicted / insufficient |
playtest |
Object: {session{testers, build, focus}, claims[{claim, entries[], support, evidence, action}], unheard[{entry, why}], build_notes[{priority, change, expected}], note}. claims[].support is supported, contradicted, unsupported or no data. build_notes[].priority is P0, P1 or P2, ordered P0 first, and P0 is reserved for something that made the session unplayable or unrepresentative. |
Route on lane, then read the key with the same name. The other two are absent, not
null — a client that reaches for economy on an audit reply is reading a
lane it did not ask for. Because block and lane share a name, result[result.lane] is a
legitimate one-liner here; it is still worth guarding, since a malformed reply is exactly the case
where it returns undefined.
Note the verdict tokens are per lane and do not overlap. tunable only ever
appears on an audit; grindy only on an economy;
partly confirmed — two words, with a space — only on a playtest. A verdict
from the wrong list is a malformed reply, and validating it against the lane is a two-line check
that catches a surprising amount.
The checks each lane returns
Eight for audit, eight for economy, seven for playtest,
always in this order. Knowing the list up front means you can build the table before the reply
lands and fill it in as the stream arrives.
| Lane | checks[].name, in order |
|---|---|
audit | table shape, stat coverage, power spread, cost efficiency, dominated entries, tier progression, outliers, degenerate strategies |
economy | price ladder, power-to-price fit, currency sinks, currency faucets, grind time, drop table integrity, rarity coherence, paywall pressure |
playtest | notes parsed, entries matched, numeric support, contradictions, unexplained complaints, severity ranking, next build actions |
The status vocabulary is fixed too. pass — the table is fine on this axis.
warn — it works but there is something the designer should know. fail —
it is wrong on this axis and a finding says so. n/a — the axis does not apply to this
table, with the note saying why. A table with no income column, for instance, gets
n/a or warn on currency faucets rather than an invented
income rate; a table with no drop-weight column gets n/a on
drop table integrity and rarity coherence.
The coverage_check rule
This is the one part of the output you can verify without reading a word of it. For every flag in
facts.flags, in the same order, there is exactly one entry:
"coverage_check": [
{"flag": "BX-001", "handled": true,
"note": "Bone Reaper is the first dead_content entry; the fix drops its cost to 640."},
{"flag": "BX-002", "handled": true,
"note": "Void Blade is the auto_include; the retune moves crit_chance rather than cost, which intent locks."},
{"flag": "BX-003", "handled": false,
"note": "Warhammer's price sits at tier 2, whose costs intent says ship next week and cannot move."}
]
Three assertions, all cheap, all worth writing once:
- Same set. The multiset of
coverage_check[].flagequals the multiset offacts.flags[].id. A missing id means something went unanswered; an extra id means the model invented a flag, which is the strongest signal available that the rest of the reply is untrustworthy. - Same order. They arrive in the order you sent them. An out-of-order list is not fatal, but it usually travels with a dropped entry.
handled: falsecarries a reason. A false with an empty or hand-wavingnoteis worse than a missing entry, because it looks answered. The honest false — "that column is locked byintent", "that flag is about a row the clip removed" — is a legitimate and common outcome.
If you sent no facts, coverage_check is [] and there is
nothing to check. That is the trade you made; see above.
Step by step
1. Get a token
Every call needs Authorization: Bearer <token>. Two ways to get one:
- Your own account token — open the token panel, sign
in, and copy it. No DevTools, no digging through storage: the panel mints a token scoped to
balance-deskand shows it once. This is the one that spends your credits and sees your run history. - A guest token —
POST /v1/app-api/guestwith{"slug":"balance-desk"}and noAuthorizationheader at all mints an anonymous subject. Guests can call/meand/estimate; a/runis metered and this app does not sponsor guests, so a guest run returns403 FORBIDDEN.
Keep the token out of your source. Read it from the environment at runtime and never commit it.
# An account token: copy it from https://balance-desk.skillsafe.ai/tokens.html
TOKEN="YOUR_TOKEN"
# Or mint a guest token — no Authorization header on this one call.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"balance-desk"}'
# -> {"ok":true,"data":{"token":"aut_...","guest_id":"gst_...","subject_type":"guest"}}
import json, os, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
# Preferred: an account token from the token panel, read from the environment.
TOKEN = os.environ.get("BALANCE_DESK_TOKEN") or "YOUR_TOKEN"
def mint_guest():
"""A guest can /me and /estimate, but not /run."""
req = urllib.request.Request(
BASE + "/guest", data=json.dumps({"slug": "balance-desk"}).encode(), method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]["token"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
// Paste an account token from /tokens.html, or inject it at runtime.
const TOKEN = "YOUR_TOKEN";
// A guest token: no Authorization header on this one call.
async function mintGuest() {
const res = await fetch(`${BASE}/guest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "balance-desk" })
});
const json = await res.json();
return json.data.token; // guests can /me and /estimate, not /run
}
package main
import (
"bytes"
"encoding/json"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
func token() string {
if t := os.Getenv("BALANCE_DESK_TOKEN"); t != "" {
return t
}
return "YOUR_TOKEN"
}
func mintGuest() (string, error) {
body, _ := json.Marshal(map[string]string{"slug": "balance-desk"})
res, err := http.Post(base+"/guest", "application/json", bytes.NewReader(body))
if err != nil {
return "", err
}
defer res.Body.Close()
var out struct {
Data struct{ Token string } `json:"data"`
}
json.NewDecoder(res.Body).Decode(&out)
return out.Data.Token, nil
}
import java.net.URI;
import java.net.http.*;
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
// An account token from /tokens.html, via the environment.
static final String TOKEN =
System.getenv("BALANCE_DESK_TOKEN") != null
? System.getenv("BALANCE_DESK_TOKEN") : "YOUR_TOKEN";
static String mintGuest() throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"balance-desk\"}"))
.build();
// -> {"ok":true,"data":{"token":"aut_...","subject_type":"guest"}}
return HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV["BALANCE_DESK_TOKEN"] || "YOUR_TOKEN"
def mint_guest
uri = URI(BASE + "/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.dump({ "slug" => "balance-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body)["data"]["token"] # guests cannot /run
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$token = getenv("BALANCE_DESK_TOKEN") ?: "YOUR_TOKEN";
function mint_guest(): string {
$ch = curl_init(BASE . "/guest");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["slug" => "balance-desk"]),
]);
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
return $body["data"]["token"]; // guests cannot /run
}
using System.Net.Http.Json;
using System.Text.Json;
const string Base = "https://api.skillsafe.ai/v1/app-api";
var token = Environment.GetEnvironmentVariable("BALANCE_DESK_TOKEN") ?? "YOUR_TOKEN";
static async Task<string> MintGuest() {
using var anon = new HttpClient();
var res = await anon.PostAsJsonAsync(Base + "/guest", new { slug = "balance-desk" });
var doc = await res.Content.ReadFromJsonAsync<JsonElement>();
return doc.GetProperty("data").GetProperty("token").GetString()!; // no /run for guests
}
2. A tiny client helper
Two things repeat on every call: the Authorization header, and unwrapping
data out of the envelope. Write them once. Everything after this step uses the
call helper below, and every one of these raises on ok: false instead of
returning a half-empty object.
# The shell equivalent of a helper: a function plus jq for the unwrap.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"
bd() { # bd GET /me | bd POST /estimate "$body"
curl -s -X "$1" "$BASE$2" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
${3:+-d "$3"} \
| jq -e 'if .ok then .data else error("\(.error.code): \(.error.message)") end'
}
import json, urllib.error, urllib.request
class ApiError(Exception):
pass
def call(path, payload=None, method=None, headers=None):
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(BASE + path, data=data,
method=method or ("POST" if data else "GET"))
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req) as r:
body = json.load(r)
except urllib.error.HTTPError as e:
body = json.load(e)
if not body.get("ok"):
err = body.get("error", {})
raise ApiError(f"{err.get('code')}: {err.get('message')}")
return body["data"]
async function call(path, payload, { method, headers } = {}) {
const res = await fetch(BASE + path, {
method: method || (payload ? "POST" : "GET"),
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
...(headers || {})
},
body: payload ? JSON.stringify(payload) : undefined
});
const json = await res.json();
if (!json.ok) throw new Error(`${json.error?.code}: ${json.error?.message}`);
return json.data;
}
type apiError struct{ Code, Message string }
func (e apiError) Error() string { return e.Code + ": " + e.Message }
func call(method, path string, payload any, headers map[string]string) (map[string]any, error) {
var body *bytes.Reader
if payload != nil {
b, _ := json.Marshal(payload)
body = bytes.NewReader(b)
} else {
body = bytes.NewReader(nil)
}
req, _ := http.NewRequest(method, base+path, body)
req.Header.Set("Authorization", "Bearer "+token())
req.Header.Set("Content-Type", "application/json")
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 out struct {
Ok bool `json:"ok"`
Data map[string]any `json:"data"`
Error apiError `json:"error"`
}
json.NewDecoder(res.Body).Decode(&out)
if !out.Ok {
return nil, out.Error
}
return out.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;
static final HttpClient HTTP = HttpClient.newHttpClient();
/** Returns the raw body; parse it with your JSON library of choice. */
static String call(String method, String path, String json, Map<String, String> headers)
throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json");
if (headers != null) headers.forEach(b::header);
b = (json == null) ? b.GET() : b.method(method, HttpRequest.BodyPublishers.ofString(json));
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
if (res.body().contains("\"ok\":false")) throw new RuntimeException(res.body());
return res.body(); // {"ok":true,"data":{...}}
}
class ApiError < StandardError; end
def call(path, payload = nil, method: nil, headers: {})
uri = URI(BASE + path)
verb = method || (payload ? "POST" : "GET")
req = (verb == "POST" ? Net::HTTP::Post : Net::HTTP::Get).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
headers.each { |k, v| req[k] = v }
req.body = JSON.dump(payload) if payload
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
body = JSON.parse(res.body)
raise ApiError, "#{body.dig('error', 'code')}: #{body.dig('error', 'message')}" unless body["ok"]
body["data"]
end
<?php
class ApiError extends RuntimeException {}
function call(string $path, ?array $payload = null, array $headers = []) {
global $token;
$ch = curl_init(BASE . $path);
$hdr = array_merge(
["Authorization: Bearer $token", "Content-Type: application/json"], $headers);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $hdr,
]);
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
}
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($body["ok"])) {
throw new ApiError(($body["error"]["code"] ?? "ERROR") . ": "
. ($body["error"]["message"] ?? ""));
}
return $body["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
async Task<JsonElement> Call(string path, object? payload = null,
(string Name, string Value)? header = null) {
var msg = new HttpRequestMessage(
payload is null ? HttpMethod.Get : HttpMethod.Post, Base + path);
if (payload is not null) msg.Content = JsonContent.Create(payload);
if (header is not null) msg.Headers.Add(header.Value.Name, header.Value.Value);
var res = await http.SendAsync(msg);
var doc = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!doc.GetProperty("ok").GetBoolean()) {
var e = doc.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return doc.GetProperty("data");
}
3. Check the session and the balance
GET /me tells you which subject the token belongs to and how many credits it holds.
Read two fields: subject_type (user or guest) and
credits. Do this before a run — a guest here means the run will come back
403 no matter how healthy the balance looks, and comparing credits
against the estimate's min_credits is how you avoid a 402 after
submitting.
curl -s -X GET "https://api.skillsafe.ai/v1/app-api/me" \
-H "Authorization: Bearer $TOKEN"
# -> {"ok":true,"data":{"subject_type":"user","credits":41230,"app":"balance-desk"}}
me = call("/me")
print(me["subject_type"], me["credits"])
if me["subject_type"] != "user":
raise SystemExit("a guest cannot run a lane in this app")
const me = await call("/me");
console.log(me.subject_type, me.credits);
if (me.subject_type !== "user") throw new Error("a guest cannot run a lane in this app");
me, err := call("GET", "/me", nil, nil)
if err != nil {
panic(err)
}
fmt.Println(me["subject_type"], me["credits"])
if me["subject_type"] != "user" {
panic("a guest cannot run a lane in this app")
}
String me = call("GET", "/me", null, null);
System.out.println(me); // {"ok":true,"data":{"subject_type":"user","credits":41230}}
if (!me.contains("\"subject_type\":\"user\""))
throw new RuntimeException("a guest cannot run a lane in this app");
me = call("/me")
puts "#{me['subject_type']} #{me['credits']}"
abort "a guest cannot run a lane in this app" unless me["subject_type"] == "user"
$me = call("/me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
if ($me["subject_type"] !== "user") {
exit("a guest cannot run a lane in this app\n");
}
var me = await Call("/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");
if (me.GetProperty("subject_type").GetString() != "user")
throw new Exception("a guest cannot run a lane in this app");
4. Estimate the lane — free, no job
POST /estimate takes the same body as /run — the input object, unwrapped
— costs nothing and creates no job. Assert three things on the way back, because they are the
contract this page is written against:
modelisgpt-5.6-terramodel_aliasisgpt-terramarkup_bpsis1000
It also returns hold_credits, min_credits and
sponsor_enabled (false here — that is why guests cannot run).
hold_credits is a reservation, not a price. It is the ceiling the
platform sets aside while the job runs, sized for the worst case of that lane's output cap. The
charged_credits you see on the settled job is usually far lower — a reply that comes
in short is billed short. Budget against hold_credits so a run is never rejected
mid-flight; report against charged_credits.
Re-estimate on every lane change. The hold differs per lane because the prompt
sections and the output caps differ. economy carries the widest ceiling of the three:
it returns a ladder, a sink list, a faucet list, a drop-table read with its own fix list and a
reprice list, so its worst case is several arrays deep. playtest scales with the input
instead — one claims entry per distinct point in notes, so a long session
writeup prices very differently from a three-line one, and the same table with different
notes is a different estimate. audit is the tightest of the three. An
estimate for audit does not price economy. The web app re-estimates on
every lane switch for exactly this reason.
The size of table and facts drives the input side of the hold, and
facts is not small — a forty-row table with six stat columns produces a
power_table, a stat_summary and a residual list. That is a good trade,
but budget for it: sending facts typically moves the hold more than sending the table did.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"task":"audit","table":"name,tier,cost,damage\nRusted Dagger,1,60,8\nVoid Blade,3,900,52","genre":"roguelike","intent":"tier 1 and 2 costs are locked","notes":"","carryover":"","facts":null}'
# -> {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":8800,"min_credits":8800,"sponsor_enabled":false}}
inp = {"task": "audit", "table": table_text, "genre": "roguelike",
"intent": "tier 1 and 2 costs are locked", "notes": "",
"carryover": "", "facts": facts_obj}
est = call("/estimate", inp) # the input object IS the body
assert est["model"] == "gpt-5.6-terra", est["model"]
assert est["model_alias"] == "gpt-terra", est["model_alias"]
assert est["markup_bps"] == 1000, est["markup_bps"]
print(est["hold_credits"], est["min_credits"], est["sponsor_enabled"])
const input = {
task: "audit", table: tableText, genre: "roguelike",
intent: "tier 1 and 2 costs are locked", notes: "", carryover: "", facts: factsObj
};
const est = await call("/estimate", input); // no { input: ... } wrapper
if (est.model !== "gpt-5.6-terra") throw new Error(`unexpected model ${est.model}`);
if (est.model_alias !== "gpt-terra") throw new Error(`unexpected alias ${est.model_alias}`);
if (est.markup_bps !== 1000) throw new Error(`unexpected markup ${est.markup_bps}`);
console.log(est.hold_credits, est.min_credits, est.sponsor_enabled);
input := map[string]any{
"task": "audit", "table": tableText, "genre": "roguelike",
"intent": "tier 1 and 2 costs are locked", "notes": "",
"carryover": "", "facts": factsObj,
}
est, err := call("POST", "/estimate", input, nil)
if err != nil {
panic(err)
}
if est["model"] != "gpt-5.6-terra" || est["model_alias"] != "gpt-terra" {
panic(fmt.Sprintf("unexpected model %v", est["model"]))
}
fmt.Println(est["markup_bps"], est["hold_credits"], est["min_credits"])
// jsonQuoted() is your JSON string escaper; the table is a STRING with real newlines.
// factsJson is the measured facts serialised as a JSON OBJECT, or the literal null.
String body = "{\"task\":\"audit\",\"table\":" + jsonQuoted(tableText)
+ ",\"genre\":\"roguelike\""
+ ",\"intent\":\"tier 1 and 2 costs are locked\""
+ ",\"notes\":\"\",\"carryover\":\"\",\"facts\":" + factsJson + "}";
String est = call("POST", "/estimate", body, null);
if (!est.contains("\"model\":\"gpt-5.6-terra\"")) throw new RuntimeException(est);
if (!est.contains("\"model_alias\":\"gpt-terra\"")) throw new RuntimeException(est);
if (!est.contains("\"markup_bps\":1000")) throw new RuntimeException(est);
System.out.println(est);
input = { "task" => "audit", "table" => table_text, "genre" => "roguelike",
"intent" => "tier 1 and 2 costs are locked", "notes" => "",
"carryover" => "", "facts" => facts_obj }
est = call("/estimate", input)
raise "unexpected model #{est['model']}" unless est["model"] == "gpt-5.6-terra"
raise "unexpected alias #{est['model_alias']}" unless est["model_alias"] == "gpt-terra"
raise "unexpected markup #{est['markup_bps']}" unless est["markup_bps"] == 1000
puts est["hold_credits"], est["min_credits"]
$input = ["task" => "audit", "table" => $tableText, "genre" => "roguelike",
"intent" => "tier 1 and 2 costs are locked", "notes" => "",
"carryover" => "", "facts" => $factsObj];
$est = call("/estimate", $input);
if ($est["model"] !== "gpt-5.6-terra" || $est["model_alias"] !== "gpt-terra") {
throw new RuntimeException("unexpected model " . $est["model"]);
}
if ($est["markup_bps"] !== 1000) {
throw new RuntimeException("unexpected markup " . $est["markup_bps"]);
}
echo $est["hold_credits"], " ", $est["min_credits"], PHP_EOL;
var input = new {
task = "audit", table = tableText, genre = "roguelike",
intent = "tier 1 and 2 costs are locked", notes = "", carryover = "", facts = factsObj
};
var est = await Call("/estimate", input);
if (est.GetProperty("model").GetString() != "gpt-5.6-terra") throw new Exception("model");
if (est.GetProperty("model_alias").GetString() != "gpt-terra") throw new Exception("alias");
if (est.GetProperty("markup_bps").GetInt32() != 1000) throw new Exception("markup");
Console.WriteLine(est.GetProperty("hold_credits"));
5. Run it, then poll the job
POST /run takes the input object as the body and returns
{"job_id": "job_…", "status": "queued"} immediately. Poll
GET /jobs/{job_id} until status is terminal —
succeeded, failed or cancelled — no faster than once a
second, and back off on a 429. The reply is the string at
job.output.output; parse it as JSON.
Always send an Idempotency-Key header, and derive it from a hash of
(task, table, notes, attempt). The lane belongs in the key because two lanes
over the same table are two distinct runs and must not collide; the table belongs in it because the
same lane over an edited table is a new run — and editing the table between lanes is exactly what
this app encourages; notes belongs in it because on the playtest lane the
same table with a different session writeup is an entirely different answer; and the attempt
counter belongs in it because a deliberate re-run of an identical input is a second answer you are
choosing to pay for.
A retry must reuse the same key. A network timeout, a dropped connection, a
500 — none of those tell you whether the job was created. Replaying the request with
the same key returns the original job instead of starting a second one. Minting a fresh key on
retry is how you get billed twice for one run, and nothing downstream will tell you it happened:
you will simply have two jobs and one answer you wanted.
Leave facts out of the key. It is derived from table, so hashing it adds
nothing — and if you improve your reader between attempts you want the retry to still land on the
original job rather than quietly starting a second billable one.
BODY='{"task":"audit","table":"name,tier,cost,damage\nRusted Dagger,1,60,8\nVoid Blade,3,900,52","genre":"roguelike","intent":"tier 1 and 2 costs are locked","notes":"","carryover":"","facts":null}'
# The key: lane, a hash of the table and notes, and the attempt number.
DIGEST=$(printf '%s' "audit$BODY" | shasum -a 256 | cut -c1-12)
KEY="balance-desk:audit:$DIGEST:a1"
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$BODY"
# -> {"ok":true,"data":{"job_id":"job_123","status":"queued"}}
# Poll. Same key on any retry of the POST above, or you pay twice.
curl -s -X GET "https://api.skillsafe.ai/v1/app-api/jobs/job_123" \
-H "Authorization: Bearer $TOKEN"
import hashlib, json, time
def idem_key(inp, attempt=1):
"""(task, table, notes, attempt) -> one stable key. Reuse it on every retry."""
digest = hashlib.sha256(
"\x00".join([inp["task"], inp["table"], inp.get("notes") or ""]).encode()
).hexdigest()[:12]
return f"balance-desk:{inp['task']}:{digest}:a{attempt}"
key = idem_key(inp)
job = call("/run", inp, headers={"Idempotency-Key": key})
while job["status"] not in ("succeeded", "failed", "cancelled"):
time.sleep(1.5)
job = call("/jobs/" + job["job_id"])
if job["status"] != "succeeded":
raise SystemExit(job.get("error") or job["status"])
result = json.loads(job["output"]["output"])
print(result["lane"], result["verdict"], job.get("charged_credits"))
import { createHash } from "node:crypto";
// (task, table, notes, attempt) -> one stable key. Reuse it on every retry.
function idemKey(input, attempt = 1) {
const digest = createHash("sha256")
.update([input.task, input.table, input.notes || ""].join("\u0000"))
.digest("hex")
.slice(0, 12);
return `balance-desk:${input.task}:${digest}:a${attempt}`;
}
const key = idemKey(input);
let job = await call("/run", input, { headers: { "Idempotency-Key": key } });
while (!["succeeded", "failed", "cancelled"].includes(job.status)) {
await new Promise(r => setTimeout(r, 1500));
job = await call(`/jobs/${job.job_id}`);
}
if (job.status !== "succeeded") throw new Error(job.error || job.status);
const result = JSON.parse(job.output.output);
console.log(result.lane, result.verdict, job.charged_credits);
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
)
// (task, table, notes, attempt) -> one stable key. Reuse it on every retry.
func idemKey(task, table, notes string, attempt int) string {
sum := sha256.Sum256([]byte(strings.Join([]string{task, table, notes}, "\x00")))
return fmt.Sprintf("balance-desk:%s:%s:a%d", task, hex.EncodeToString(sum[:])[:12], attempt)
}
key := idemKey("audit", tableText, "", 1)
job, err := call("POST", "/run", input, map[string]string{"Idempotency-Key": key})
if err != nil {
panic(err)
}
for {
status, _ := job["status"].(string)
if status == "succeeded" || status == "failed" || status == "cancelled" {
break
}
time.Sleep(1500 * time.Millisecond)
job, _ = call("GET", "/jobs/"+job["job_id"].(string), nil, nil)
}
// job["output"].(map[string]any)["output"].(string) is the reply JSON
import java.security.MessageDigest;
// (task, table, notes, attempt) -> one stable key. Reuse it on every retry.
static String idemKey(String task, String table, String notes, int attempt) throws Exception {
byte[] d = MessageDigest.getInstance("SHA-256")
.digest((task + "\0" + table + "\0" + notes).getBytes("UTF-8"));
StringBuilder hex = new StringBuilder();
for (int i = 0; i < 6; i++) hex.append(String.format("%02x", d[i]));
return "balance-desk:" + task + ":" + hex + ":a" + attempt;
}
String key = idemKey("audit", tableText, "", 1);
String job = call("POST", "/run", body, Map.of("Idempotency-Key", key));
// then poll GET /jobs/{job_id} on the same helper until status is terminal,
// resending /run with THIS key — never a new one — if the POST itself failed.
require "digest"
# (task, table, notes, attempt) -> one stable key. Reuse it on every retry.
def idem_key(input, attempt = 1)
seed = [input["task"], input["table"], input["notes"].to_s].join("\0")
"balance-desk:#{input['task']}:#{Digest::SHA256.hexdigest(seed)[0, 12]}:a#{attempt}"
end
key = idem_key(input)
job = call("/run", input, headers: { "Idempotency-Key" => key })
until %w[succeeded failed cancelled].include?(job["status"])
sleep 1.5
job = call("/jobs/#{job['job_id']}")
end
abort(job["error"].to_s) unless job["status"] == "succeeded"
result = JSON.parse(job.dig("output", "output"))
puts result["lane"], result["verdict"]
<?php
// (task, table, notes, attempt) -> one stable key. Reuse it on every retry.
function idem_key(array $input, int $attempt = 1): string {
$seed = $input["task"] . "\0" . $input["table"] . "\0" . ($input["notes"] ?? "");
$digest = substr(hash("sha256", $seed), 0, 12);
return "balance-desk:{$input['task']}:{$digest}:a{$attempt}";
}
$key = idem_key($input);
$job = call("/run", $input, ["Idempotency-Key: $key"]);
while (!in_array($job["status"], ["succeeded", "failed", "cancelled"], true)) {
usleep(1500000);
$job = call("/jobs/" . $job["job_id"]);
}
if ($job["status"] !== "succeeded") {
throw new RuntimeException($job["error"] ?? $job["status"]);
}
$result = json_decode($job["output"]["output"], true);
echo $result["lane"], " ", $result["verdict"], PHP_EOL;
using System.Security.Cryptography;
using System.Text;
// (task, table, notes, attempt) -> one stable key. Reuse it on every retry.
static string IdemKey(string task, string table, string notes, int attempt = 1) {
var seed = string.Join("\0", new[] { task, table, notes ?? "" });
var d = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
return $"balance-desk:{task}:{Convert.ToHexString(d)[..12].ToLowerInvariant()}:a{attempt}";
}
var key = IdemKey("audit", tableText, "");
var job = await Call("/run", input, ("Idempotency-Key", key));
var jobId = job.GetProperty("job_id").GetString();
string status;
do {
await Task.Delay(1500);
job = await Call($"/jobs/{jobId}");
status = job.GetProperty("status").GetString()!;
} while (status is not ("succeeded" or "failed" or "cancelled"));
var result = JsonDocument.Parse(
job.GetProperty("output").GetProperty("output").GetString()!).RootElement;
Console.WriteLine(result.GetProperty("verdict"));
6. Or stream it
POST /run-stream is the same call, the same body and the same
Idempotency-Key, delivered as server-sent events. Each line of interest starts with
data: and carries one JSON event with a type:
job— arrives first, as soon as the job exists, carryingjob_id. Keep it: if the stream dies you can fall back to pollingGET /jobs/{job_id}for the same run rather than paying for a second one.delta— a text fragment of the reply. Appendevent.textto a buffer. Fragments are not JSON on their own and are not line-aligned; do not try to parse one.job, terminal — the settled job at the end of the stream, withstatus,output,charged_creditsandtruncated. This is the authoritative record; the concatenated deltas are only a preview of it.
The web app uses this route so its progress card can advance as sections arrive. Streaming earns
its keep here because of where the long tail sits in this contract: verdict,
headline and overview land in the first few hundred characters, the
checks table fills in next, and then the lane block — the retune list, the ladder and
the sinks, or a claims entry for every point in a long session writeup — arrives last
and is usually the bulk of the wait. A caller who renders the checks as they arrive gives a
designer something to read a long way before the run settles. If the stream dies mid-flight, keep
what arrived — see step 7 for closing a truncated buffer rather than throwing
the run away.
curl -s -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$BODY"
# data: {"type":"job","job_id":"job_123","status":"running"}
# data: {"type":"delta","text":"{\"lane\":\"audit\","}
# data: {"type":"delta","text":"\"table_name\":\"early-game roguelike weapon"}
# ...
# data: {"type":"job","job_id":"job_123","status":"succeeded","charged_credits":2410,
# "truncated":false,"output":{"output":"{...the whole reply...}"}}
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps(inp).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key) # the same key as /run
buf, job = "", None
with urllib.request.urlopen(req) as stream:
for raw in stream:
line = raw.decode("utf-8").strip()
if not line.startswith("data:"):
continue
evt = json.loads(line[5:])
if evt.get("type") == "delta":
buf += evt["text"] # a preview, not parseable yet
elif evt.get("type") == "job":
job = evt # first one has the id, last one settles
# Prefer the terminal job; fall back to the buffer if the stream died.
raw_result = job["output"]["output"] if job and job.get("output") else buf
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json",
"Idempotency-Key": key }, // the same key as /run
body: JSON.stringify(input)
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let pending = "", buf = "", job = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
pending += decoder.decode(value, { stream: true });
const lines = pending.split("\n");
pending = lines.pop(); // keep the partial line
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const evt = JSON.parse(line.slice(5));
if (evt.type === "delta") buf += evt.text;
else if (evt.type === "job") job = evt; // id first, settled job last
}
}
const rawResult = job?.output?.output ?? buf;
b, _ := json.Marshal(input)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token())
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key) // the same key as /run
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var buf strings.Builder
scanner := bufio.NewScanner(res.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
var evt struct {
Type string `json:"type"`
Text string `json:"text"`
JobID string `json:"job_id"`
}
json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &evt)
switch evt.Type {
case "delta":
buf.WriteString(evt.Text)
case "job":
jobID = evt.JobID // keep it: a dead stream can be recovered by polling
}
}
HttpResponse<Stream<String>> res = HTTP.send(
HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key) // the same key as /run
.POST(HttpRequest.BodyPublishers.ofString(body)).build(),
HttpResponse.BodyHandlers.ofLines());
StringBuilder buf = new StringBuilder();
res.body()
.filter(l -> l.startsWith("data:"))
.map(l -> l.substring(5))
.forEach(payload -> {
// parse payload with your JSON library:
// type "delta" -> buf.append(text)
// type "job" -> remember job_id, and the terminal job settles the run
if (payload.contains("\"type\":\"delta\"")) buf.append(textOf(payload));
});
uri = URI(BASE + "/run-stream")
buf = +""
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key # the same key as /run
req.body = JSON.dump(input)
pending = +""
http.request(req) do |res|
res.read_body do |chunk|
pending << chunk
while (nl = pending.index("\n"))
line = pending.slice!(0, nl + 1).strip
next unless line.start_with?("data:")
evt = JSON.parse(line[5..])
buf << evt["text"] if evt["type"] == "delta"
@job = evt if evt["type"] == "job"
end
end
end
end
<?php
$buf = "";
$pending = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
"Idempotency-Key: $key", // the same key as /run
],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$pending) {
$pending .= $chunk;
while (($nl = strpos($pending, "\n")) !== false) {
$line = trim(substr($pending, 0, $nl));
$pending = substr($pending, $nl + 1);
if (strncmp($line, "data:", 5) !== 0) continue;
$evt = json_decode(substr($line, 5), true);
if (($evt["type"] ?? "") === "delta") $buf .= $evt["text"];
// ($evt["type"] ?? "") === "job" -> the id first, the settled job last
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var msg = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream") {
Content = JsonContent.Create(input)
};
msg.Headers.Add("Idempotency-Key", key); // the same key as /run
using var res = await http.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var buf = new StringBuilder();
JsonElement? settled = null;
while (await reader.ReadLineAsync() is string line) {
if (!line.StartsWith("data:")) continue;
var evt = JsonDocument.Parse(line[5..]).RootElement;
var type = evt.GetProperty("type").GetString();
if (type == "delta") buf.Append(evt.GetProperty("text").GetString());
else if (type == "job") settled = evt; // id first, settled job last
}
7. Parse the result
Five moves, in this order, whichever route you took:
- Get the JSON object out of the reply. The model is instructed to emit one object
and nothing else — first character
{, last character}— so a strict parse ofoutput.outputnormally works. Be tolerant anyway: slice from the first{to the last}before parsing, so a stray```jsonfence or a leading newline is not an outage. - Route on
lane, not on thetaskyou sent. An unrecognisedtaskis answered by the nearest lane, and that lane is whatlanesays. Iflaneis not the task you sent, that is not an error — but it is worth logging, because it means yourtaskvalue did not match one of the three ids and something upstream is probably wrong. - Read that lane's block and nothing else.
audit,economy,playtest— exactly one is present, and it is named after the lane, soresult[result.lane]gets it. - Check
coverage_checkagainst the flags you sent. EveryBX-nnninfacts.flagsappears exactly once. A missing id, a duplicate, or an id you never sent are all signals to distrust the reply rather than render it. - Handle a truncated reply. When the job carries
"truncated": true, or when a stream died, close the buffer at the last complete structure and render what parsed.headline,overviewand the first checks arrive early and are worth showing; throwing the whole run away because the last tworetunerows never landed wastes a credit you have already spent.
JOB=$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/job_123" \
-H "Authorization: Bearer $TOKEN")
# The reply is a JSON string inside the job. Unwrap it once, then read it.
RESULT=$(echo "$JOB" | jq -r '.data.output.output')
echo "$RESULT" | jq -r '.lane, .table_name, .verdict, .headline'
echo "$RESULT" | jq -r '.checks[] | "\(.status)\t\(.name)"'
echo "$RESULT" | jq -r '.findings[] | "\(.id)\t\(.severity)\t\(.where)\t\(.change)"'
echo "$RESULT" | jq -r '.coverage_check[] | "\(.flag)\thandled=\(.handled)"'
# The lane block is named after the lane: exactly one of these is present.
echo "$RESULT" | jq '.[.lane]'
# Lane-specific reads:
echo "$RESULT" | jq -r '.audit.retune[]? | "\(.entry) \(.field): \(.from) -> \(.to)"'
echo "$RESULT" | jq -r '.economy.reprice[]? | "\(.entry): \(.from) -> \(.to)"'
echo "$RESULT" | jq -r '.playtest.claims[]? | "\(.support)\t\(.claim)"'
# Was it cut short?
echo "$JOB" | jq '.data.truncated, .data.charged_credits'
def parse_result(raw):
"""Tolerant parse: strip a stray fence, then slice to the outermost braces."""
text = raw.strip()
if text.startswith("```"):
text = text.split("\n", 1)[-1]
if text.rstrip().endswith("```"):
text = text.rstrip()[:-3]
start, end = text.find("{"), text.rfind("}")
if start < 0:
raise ValueError("no JSON object in the reply")
return json.loads(text[start:end + 1])
result = parse_result(job["output"]["output"])
lane = result["lane"] # route on this, not on inp["task"]
if lane != inp["task"]:
print(f"note: asked for {inp['task']}, the model ran {lane}")
block = result.get(lane) # the block is named after the lane
print(result["verdict"], "-", result["headline"])
for c in result.get("checks", []):
print(f" [{c['status']:4}] {c['name']}: {c['note']}")
for f in result.get("findings", []):
print(f" {f['id']} {f['severity']}: {f['title']} @ {f['where']} | {f['change']}")
# Every BX id you sent comes back exactly once, in order.
sent = [fl["id"] for fl in (inp.get("facts") or {}).get("flags", [])]
got = [cc["flag"] for cc in result.get("coverage_check", [])]
if sent != got:
print("coverage mismatch - missing:", set(sent) - set(got),
"invented:", set(got) - set(sent))
if lane == "audit":
for r in block["retune"]:
print(f"{r['entry']} {r['field']}: {r['from']} -> {r['to']} ({r['reason']})")
elif lane == "economy":
print(block["grind"]["worst"], block["grind"]["units"])
elif lane == "playtest":
for c in block["claims"]:
print(f"[{c['support']}] {c['claim']}")
if job.get("truncated"):
# Render what parsed; do not discard the run.
print("reply was cut short - sections above are complete, the rest is missing")
function parseResult(raw) {
let text = raw.trim();
if (text.startsWith("```")) { // strip a stray code fence
text = text.slice(text.indexOf("\n") + 1).replace(/```\s*$/, "");
}
const start = text.indexOf("{"), end = text.lastIndexOf("}");
if (start < 0) throw new Error("no JSON object in the reply");
try {
return JSON.parse(text.slice(start, end + 1));
} catch (_) {
// A dead stream: close the buffer at the last complete top-level entry.
const cut = text.lastIndexOf("],");
if (cut < 0) throw new Error("nothing parseable arrived");
return JSON.parse(text.slice(start, cut + 1) + "}");
}
}
const result = parseResult(rawResult);
if (result.lane !== input.task) {
console.warn(`asked for ${input.task}, the model ran ${result.lane}`);
}
const block = result[result.lane]; // the block is named after the lane
console.log(result.verdict, "-", result.headline);
result.checks?.forEach(c => console.log(` [${c.status}] ${c.name}: ${c.note}`));
result.findings?.forEach(f => console.log(` ${f.id} ${f.severity}: ${f.title} @ ${f.where}`));
// Every BX id you sent comes back exactly once, in order.
const sent = (input.facts?.flags ?? []).map(f => f.id);
const got = (result.coverage_check ?? []).map(cc => cc.flag);
if (sent.join(",") !== got.join(",")) {
console.warn("coverage mismatch",
{ missing: sent.filter(id => !got.includes(id)),
invented: got.filter(id => !sent.includes(id)) });
}
if (result.lane === "audit") console.table(block.retune);
if (result.lane === "economy") console.table(block.reprice);
if (result.lane === "playtest") console.table(block.claims);
if (job?.truncated) console.warn("reply cut short - rendering the sections that parsed");
raw := job["output"].(map[string]any)["output"].(string)
raw = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(raw, "```json"), "```"))
if i, j := strings.Index(raw, "{"), strings.LastIndex(raw, "}"); i >= 0 && j > i {
raw = raw[i : j+1]
}
var result map[string]any
if err := json.Unmarshal([]byte(raw), &result); err != nil {
// Truncated: render whatever the caller already streamed rather than dropping it.
log.Printf("reply not parseable in full: %v", err)
}
lane, _ := result["lane"].(string) // route on this, not on the task you sent
block, _ := result[lane].(map[string]any)
fmt.Println(result["verdict"], result["headline"], lane)
for _, c := range result["checks"].([]any) {
m := c.(map[string]any)
fmt.Printf(" [%v] %v: %v\n", m["status"], m["name"], m["note"])
}
// Every BX id you sent comes back exactly once, in order.
got := map[string]int{}
for _, cc := range result["coverage_check"].([]any) {
got[cc.(map[string]any)["flag"].(string)]++
}
for _, id := range sentFlagIDs {
if got[id] != 1 {
log.Printf("coverage: %s appeared %d times, expected 1", id, got[id])
}
}
if lane == "audit" {
for _, r := range block["retune"].([]any) {
m := r.(map[string]any)
fmt.Printf("%v %v: %v -> %v\n", m["entry"], m["field"], m["from"], m["to"])
}
}
if t, _ := job["truncated"].(bool); t {
fmt.Println("reply was cut short")
}
// The lane block is named after the lane, so there is no map to keep:
// audit -> "audit", economy -> "economy", playtest -> "playtest"
static String sliceObject(String raw) {
String text = raw.strip();
if (text.startsWith("```")) { // strip a stray fence
text = text.substring(text.indexOf('\n') + 1);
if (text.stripTrailing().endsWith("```"))
text = text.stripTrailing().substring(0, text.stripTrailing().length() - 3);
}
int start = text.indexOf('{'), end = text.lastIndexOf('}');
if (start < 0) throw new IllegalArgumentException("no JSON object in the reply");
return text.substring(start, Math.max(end + 1, start + 1));
}
// With your JSON library:
// var result = mapper.readTree(sliceObject(outputOutput));
// String lane = result.get("lane").asText(); // route on this
// var block = result.get(lane); // named after the lane
// for (var c : result.withArray("checks")) { ... }
// for (var f : result.withArray("findings")) { ... } // id, severity, where, change
// // coverage: every BX id you sent appears exactly once, in order
// var got = new ArrayList<String>();
// for (var cc : result.withArray("coverage_check")) got.add(cc.get("flag").asText());
// if (!got.equals(sentFlagIds)) log.warn("coverage mismatch {} vs {}", sentFlagIds, got);
// If the parse throws, render the fields you already have rather than
// discarding the run: job.truncated == true is the expected cause.
def parse_result(raw)
text = raw.strip
text = text.sub(/\A```[a-z]*\n/, "").sub(/```\z/, "").strip if text.start_with?("```")
start = text.index("{")
raise "no JSON object in the reply" unless start
JSON.parse(text[start..text.rindex("}")])
end
result = parse_result(job.dig("output", "output"))
warn "asked for #{input['task']}, the model ran #{result['lane']}" if result["lane"] != input["task"]
block = result[result["lane"]] # the block is named after the lane
puts "#{result['verdict']} - #{result['headline']}"
result.fetch("checks", []).each { |c| puts " [#{c['status']}] #{c['name']}: #{c['note']}" }
result.fetch("findings", []).each { |f| puts " #{f['id']} #{f['severity']}: #{f['title']}" }
# Every BX id you sent comes back exactly once, in order.
sent = (input.dig("facts", "flags") || []).map { |f| f["id"] }
got = result.fetch("coverage_check", []).map { |cc| cc["flag"] }
warn "coverage mismatch: #{sent - got} missing, #{got - sent} invented" if sent != got
case result["lane"]
when "audit" then block["retune"].each { |r| puts "#{r['entry']} #{r['field']}: #{r['from']} -> #{r['to']}" }
when "economy" then puts "#{block['grind']['worst']}: #{block['grind']['units']}"
when "playtest" then block["claims"].each { |c| puts "[#{c['support']}] #{c['claim']}" }
end
warn "reply cut short - rendering what parsed" if job["truncated"]
<?php
function parse_result(string $raw): array {
$text = trim($raw);
if (str_starts_with($text, "```")) { // strip a stray fence
$text = trim(preg_replace('/\A```[a-z]*\n|```\z/', "", $text));
}
$start = strpos($text, "{");
if ($start === false) throw new RuntimeException("no JSON object in the reply");
$end = strrpos($text, "}");
$obj = json_decode(substr($text, $start, $end - $start + 1), true);
if (!is_array($obj)) throw new RuntimeException("reply did not parse");
return $obj;
}
$result = parse_result($job["output"]["output"]);
if ($result["lane"] !== $input["task"]) {
error_log("asked for {$input['task']}, the model ran {$result['lane']}");
}
$block = $result[$result["lane"]] ?? null; // the block is named after the lane
printf("%s - %s\n", $result["verdict"], $result["headline"]);
foreach ($result["checks"] ?? [] as $c) {
printf(" [%s] %s: %s\n", $c["status"], $c["name"], $c["note"]);
}
// Every BX id you sent comes back exactly once, in order.
$sent = array_column($input["facts"]["flags"] ?? [], "id");
$got = array_column($result["coverage_check"] ?? [], "flag");
if ($sent !== $got) {
error_log("coverage mismatch: " . json_encode(["missing" => array_diff($sent, $got),
"invented" => array_diff($got, $sent)]));
}
if ($result["lane"] === "audit") {
foreach ($block["retune"] as $r) {
printf("%s %s: %s -> %s\n", $r["entry"], $r["field"], $r["from"], $r["to"]);
}
}
if (!empty($job["truncated"])) {
error_log("reply cut short - rendering the sections that parsed");
}
static JsonElement ParseResult(string raw) {
var text = raw.Trim();
if (text.StartsWith("```")) { // strip a stray fence
text = text[(text.IndexOf('\n') + 1)..].TrimEnd();
if (text.EndsWith("```")) text = text[..^3];
}
var start = text.IndexOf('{');
var end = text.LastIndexOf('}');
if (start < 0) throw new Exception("no JSON object in the reply");
return JsonDocument.Parse(text[start..(end + 1)]).RootElement;
}
var result = ParseResult(job.GetProperty("output").GetProperty("output").GetString()!);
var lane = result.GetProperty("lane").GetString()!; // route on this
var block = result.GetProperty(lane); // named after the lane
Console.WriteLine($"{result.GetProperty("verdict")} - {result.GetProperty("headline")}");
foreach (var c in result.GetProperty("checks").EnumerateArray())
Console.WriteLine($" [{c.GetProperty("status")}] {c.GetProperty("name")}");
// Every BX id you sent comes back exactly once, in order.
var got = result.GetProperty("coverage_check").EnumerateArray()
.Select(cc => cc.GetProperty("flag").GetString()!).ToList();
if (!got.SequenceEqual(sentFlagIds))
Console.Error.WriteLine($"coverage mismatch: sent {string.Join(",", sentFlagIds)}, got {string.Join(",", got)}");
if (lane == "audit")
foreach (var r in block.GetProperty("retune").EnumerateArray())
Console.WriteLine($"{r.GetProperty("entry")} {r.GetProperty("field")}");
if (job.TryGetProperty("truncated", out var t) && t.GetBoolean())
Console.WriteLine("reply cut short - rendering the sections that parsed");
Three worked examples, end to end
One per lane, over the same eight-row roguelike weapon shop. Each shows the request body as it goes
on the wire and the reply as it comes back, abridged in the middle but complete in shape — the
envelope fields are all there, in the order the model emits them, and the check list is the lane's
full list. The facts object is shown by its flags only; a real one carries the power
table, the stat summary and the fit as well.
Example 1 — audit
Request:
POST https://api.skillsafe.ai/v1/app-api/run
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json
Idempotency-Key: balance-desk:audit:9f3c1a77b204:a1
{"task":"audit",
"table":"name,tier,cost,damage,attack_speed,crit_chance,range,cooldown\nRusted Dagger,1,60,8,1.6,5,1.2,0.5\nHunting Bow,1,90,11,1.1,7,6.0,0.9\nIron Sword,1,120,14,1.0,5,1.4,1.0\nOak Staff,2,240,19,0.8,9,4.5,1.4\nWarhammer,2,310,27,0.6,4,1.6,1.9\nEmber Wand,3,760,38,1.2,18,5.0,1.1\nBone Reaper,3,880,44,0.9,12,2.4,1.6\nVoid Blade,3,900,52,1.3,22,1.8,0.8",
"genre":"roguelike",
"intent":"Tier 1 and 2 costs ship next week and are locked. Tier 3 is still open.",
"notes":"",
"carryover":"",
"facts":{"readable":true,"format":"csv","rows":8,
"roles":{"name":"name","tier":"tier","cost":"cost","rarity":null,"weight":null,"income":null,
"stats":["damage","attack_speed","crit_chance","range","cooldown"],
"lower_is_better":["cooldown"],"ignored":[]},
"flags":[{"id":"BX-001","level":"high","code":"strictly_dominated","title":"\"Bone Reaper\" is strictly dominated","detail":"…"},
{"id":"BX-002","level":"high","code":"dominant_pick","title":"\"Void Blade\" dominates 2 other entries","detail":"…"},
{"id":"BX-003","level":"medium","code":"overpriced","title":"\"Warhammer\" is overpriced for its power","detail":"…"}]}}
Reply, abridged:
{"ok":true,"data":{"job_id":"job_7Kq2","status":"succeeded","charged_credits":2380,"truncated":false,
"output":{"output":"{ …the object below, as one JSON string… }"}}}
{
"lane": "audit",
"table_name": "early-game roguelike weapon shop",
"verdict": "tunable",
"headline": "Void Blade is a mandatory pick and Bone Reaper is dead next to it; both live in tier 3, where costs are still open.",
"overview": "Eight weapons across three tiers, priced 60 to 900, scored on five stats with cooldown read as lower-is-better. The spread is deliberate up to tier 2 and falls apart at tier 3, where one entry dominates two of its peers at equal cost. Tier 1 and 2 costs are locked by intent, so every retune below either moves a stat or moves a tier-3 price.",
"checks": [
{"name": "table shape", "status": "pass", "note": "Eight rows, eight columns, header read cleanly as CSV."},
{"name": "stat coverage", "status": "pass", "note": "Five numeric stats, every cell populated."},
{"name": "power spread", "status": "warn", "note": "0.1042 to 0.8375 with a 0.29 gap between Void Blade and the next entry."},
{"name": "cost efficiency", "status": "warn", "note": "Void Blade's efficiency z is 2.14; Warhammer's is -0.42 at a price the fit says is 86 too high."},
{"name": "dominated entries", "status": "fail", "note": "Bone Reaper is strictly dominated by Void Blade at a lower price."},
{"name": "tier progression", "status": "warn", "note": "Mean power steps 1.9x then 2.3x; the second step is the larger of the two."},
{"name": "outliers", "status": "warn", "note": "crit_chance on Void Blade is 2.4 standard deviations above the mean."},
{"name": "degenerate strategies", "status": "fail", "note": "Buy nothing until Void Blade is affordable is strictly correct as the table stands."}
],
"findings": [
{"id": "BD-001", "severity": "high", "title": "Void Blade is an auto-include", "where": "Void Blade",
"why": "Power 0.8375 against a table median of 0.4128, dominating two tier-3 peers at equal cost.",
"fix": "Take the crit down so Ember Wand keeps its niche.", "change": "crit_chance: 22 -> 14"},
{"id": "BD-002", "severity": "high", "title": "Bone Reaper is dead content", "where": "Bone Reaper",
"why": "Beaten on every stat by Void Blade, which costs 900 against its 880.",
"fix": "Give it the only cleave in the table, or price it under the tier.", "change": "cost: 880 -> 640"},
{"id": "BD-003", "severity": "medium", "title": "Warhammer is priced above the line", "where": "Warhammer",
"why": "It costs 310 where the cost-on-power fit predicts 224 (z = 1.71).",
"fix": "Costs are locked at tier 2, so add the damage the price implies instead.", "change": "damage: 27 -> 33"}
],
"coverage_check": [
{"flag": "BX-001", "handled": true, "note": "BD-002; the dominance is the finding's evidence."},
{"flag": "BX-002", "handled": true, "note": "BD-001; retuned by stat rather than by price."},
{"flag": "BX-003", "handled": false, "note": "Warhammer's cost is locked by intent, so the flag is answered with a stat change instead of the reprice it asks for."}
],
"next_steps": [
"Drop Void Blade's crit_chance to 14 and re-run the audit.",
"Decide whether Bone Reaper gets a mechanic or a lower price.",
"Run the economy lane once tier 3 prices settle."
],
"audit": { "power_read": "…", "dead_content": [ … ], "auto_includes": [ … ],
"curve": {"shape": "geometric", "reads_as": "…", "target": "…"},
"retune": [ … ], "note": "…" }
}
Note BX-003 coming back handled: false with a real reason. That is the
contract working, not failing: intent locked tier 2 costs, so the honest answer is
that the flag was read, understood and answered a different way.
Example 2 — economy
The same sitting, one lane later. carryover carries the audit's conclusion forward,
and the table has gained a drop_weight and a gold_reward column, so the
reader now produces a drops block and an economy block and the flags
change with them.
Request:
{"task":"economy",
"table":"name,tier,rarity,cost,drop_weight,gold_reward,damage,armour\nScrap Knife,1,common,60,42,12,8,0\n…\nSunken Crown,4,legendary,9600,0.4,0,0,44",
"genre":"arpg",
"intent":"The shop and the loot table share one currency. Nothing is locked.",
"notes":"",
"carryover":"Previous lane: audit. Verdict: tunable. Tier 4 is one entry and it carries the whole top of the curve.",
"facts":{"readable":true,"format":"csv","rows":14,
"roles":{"name":"name","tier":"tier","cost":"cost","rarity":"rarity","weight":"drop_weight",
"income":"gold_reward","stats":["damage","armour"],"lower_is_better":[],"ignored":[]},
"economy":{"sinkCount":14,"faucetCount":6,"sinkTotal":31840,"faucetTotal":610,
"sinkFaucetRatio":52.197,"costMin":60,"costMax":9600,"costMedian":420,
"costSpread":160,"grindCount":6,"grindMedian":14},
"drops":{"scale":"percent","total":100,
"rows":[{"name":"Scrap Knife","weight":42,"p":0.42,"percent":42,"pulls50":2,"pulls90":5},
{"name":"Sunken Crown","weight":0.4,"p":0.004,"percent":0.4,"pulls50":173,"pulls90":575}],
"issues":[{"kind":"long_tail","detail":"…"}]},
"flags":[{"id":"BX-001","level":"high","code":"drop_rarity_inversion","title":"A rarity label contradicts its drop rate","detail":"…"},
{"id":"BX-002","level":"medium","code":"drop_long_tail","title":"One entry sits behind a very long tail","detail":"…"},
{"id":"BX-003","level":"medium","code":"grind_wall","title":"\"Sunken Crown\" is a grind wall","detail":"…"},
{"id":"BX-004","level":"low","code":"wide_cost_spread","title":"Costs span a wide range","detail":"…"}]}}
Reply, abridged:
{
"lane": "economy",
"table_name": "shared-currency ARPG loot shop",
"verdict": "grindy",
"headline": "The ladder is priced 52x what the table's own faucets pay out, and the top row sits 575 pulls deep.",
"overview": "Fourteen rows on one soft currency, sinks totalling 31840 against faucets of 610. Prices track power well up to tier 3 and then jump; the drop weights are percentages summing to exactly 100, but the labels on them do not match the odds. The audit's read that tier 4 carries the top of the curve is what the pricing confirms: it is one row, and it is priced like three.",
"checks": [
{"name": "price ladder", "status": "warn", "note": "Tier 1 to 3 ladders cleanly at roughly 2.4x a step; tier 4 jumps 22x."},
{"name": "power-to-price fit", "status": "warn", "note": "R-squared 0.71 across 14 priced entries, with Sunken Crown 2.6 residual sd above the line."},
{"name": "currency sinks", "status": "pass", "note": "All fourteen rows are sinks; totals 31840."},
{"name": "currency faucets", "status": "warn", "note": "Six rows pay out, totalling 610, but no per-session rate is stated so this is a total not a rate."},
{"name": "grind time", "status": "fail", "note": "Sunken Crown costs 320 income units against a table median of 14."},
{"name": "drop table integrity", "status": "pass", "note": "Weights are percentages and sum to 100.000 exactly."},
{"name": "rarity coherence", "status": "fail", "note": "Ashen Mail (epic) drops more often than Tidecaller Ring (rare)."},
{"name": "paywall pressure", "status": "warn", "note": "No premium currency is present, but a 0.4% drop on a 9600 row reads as a gate."}
],
"findings": [
{"id": "BD-001", "severity": "high", "title": "Rarity labels contradict the drop rates", "where": "Ashen Mail",
"why": "At 3.1% it outdrops Tidecaller Ring at 2.4%, which the table calls the rarer of the two.",
"fix": "Move 1.5 points from the epic to the legendary so the ordering holds and the column still sums to 100.",
"change": "drop_weight: 3.1 -> 1.6"},
{"id": "BD-002", "severity": "high", "title": "The top row is priced off the ladder", "where": "Sunken Crown",
"why": "9600 where the fit predicts 4180 at power 0.94, and 320 income units against a median of 14.",
"fix": "Reprice to the line, or add a second tier-4 row so the tier is a band rather than a wall.",
"change": "cost: 9600 -> 4200"}
],
"coverage_check": [
{"flag": "BX-001", "handled": true, "note": "BD-001; the reweight keeps the column at 100."},
{"flag": "BX-002", "handled": true, "note": "drop_table.fixes raises Sunken Crown to 1.9, bringing a 90% chance inside 120 pulls."},
{"flag": "BX-003", "handled": true, "note": "BD-002 and the grind block; 320 units against a median of 14."},
{"flag": "BX-004", "handled": true, "note": "The 160x spread is what the tier-4 reprice narrows; noted in ladder."}
],
"next_steps": [
"Reprice Sunken Crown to 4200 and re-run this lane.",
"Reweight Ashen Mail to 1.6 and Sunken Crown to 1.9, then confirm the column still sums to 100.",
"Add a per-session gold rate to intent so the grind can be priced in time rather than in clears."
],
"economy": { "currency_read": "…", "ladder": [ … ], "sinks": [ … ], "faucets": [ … ],
"grind": {"worst": "Sunken Crown", "units": "320 clears of a wave that pays 30", "verdict": "…"},
"drop_table": {"read": "…", "fixes": [ … ]}, "reprice": [ … ], "note": "…" }
}
Example 3 — playtest
The only lane that reads notes. Four points in the writeup become four
claims, in the order they were made, each settled against a measured number rather
than against the model's opinion — including the one that names something the table does not
contain.
Request:
{"task":"playtest",
"table":"name,tier,cost,damage,attack_speed,crit_chance,range,cooldown\nRusted Dagger,1,60,8,1.6,5,1.2,0.5\n…\nVoid Blade,3,900,52,1.3,22,1.8,0.8",
"genre":"roguelike",
"intent":"",
"notes":"Build 0.7.2, four testers, 90 minutes.\n- Everyone bought the Void Blade the moment they could afford it. Two of them said the run was over at that point.\n- Two testers said the Warhammer felt bad but could not say why.\n- One tester wanted the Hunting Bow to hit harder at range.\n- Nobody used the shield rune.",
"carryover":"Previous lane: audit. Verdict: tunable. Void Blade is the auto-include; Bone Reaper is dominated.",
"facts":{"readable":true,"format":"csv","rows":8,
"flags":[{"id":"BX-001","level":"high","code":"dominant_pick","title":"\"Void Blade\" dominates 2 other entries","detail":"…"},
{"id":"BX-002","level":"medium","code":"overpriced","title":"\"Warhammer\" is overpriced for its power","detail":"…"},
{"id":"BX-003","level":"high","code":"strictly_dominated","title":"\"Bone Reaper\" is strictly dominated","detail":"…"}]}}
Reply, abridged:
{
"lane": "playtest",
"table_name": "roguelike weapon shop, build 0.7.2",
"verdict": "partly confirmed",
"headline": "The Void Blade complaint is exactly what the numbers say; the Hunting Bow one is about a mechanic the table does not model.",
"overview": "Four testers, ninety minutes, four distinct points in the notes. Two are supported by measurement, one is unsupported because the table has no falloff term to test it with, and one names a row that is not in this table at all. The audit's read that Void Blade is the auto-include is what the session found independently.",
"checks": [
{"name": "notes parsed", "status": "pass", "note": "Four bullet points, plus a header line giving build and tester count."},
{"name": "entries matched", "status": "warn", "note": "Three of four points matched a row; the shield rune is not in this table."},
{"name": "numeric support", "status": "pass", "note": "Both matched complaints resolve against a measured number."},
{"name": "contradictions", "status": "pass", "note": "No tester claim is contradicted by the table."},
{"name": "unexplained complaints", "status": "warn", "note": "The Hunting Bow range request cannot be tested: range is a flat column."},
{"name": "severity ranking", "status": "pass", "note": "Void Blade ranks above Warhammer on both power and efficiency z."},
{"name": "next build actions", "status": "pass", "note": "Two changes, both a single number, both shippable in one build."}
],
"findings": [
{"id": "BD-001", "severity": "high", "title": "The session confirms the auto-include", "where": "Void Blade",
"why": "Four of four testers bought it first; power 0.8375 against a median of 0.4128 and efficiency z 2.14.",
"fix": "Reduce crit_chance and re-test the same shop.", "change": "crit_chance: 22 -> 14"},
{"id": "BD-002", "severity": "medium", "title": "The Warhammer complaint is a pricing complaint", "where": "Warhammer",
"why": "Testers could not name the reason; the fit can — it costs 310 where 224 is predicted (z = 1.71).",
"fix": "Drop the price rather than raising the damage; the stat line is fine for its tier.", "change": "cost: 310 -> 240"}
],
"coverage_check": [
{"flag": "BX-001", "handled": true, "note": "Claim 1 and BD-001; the session and the dominance agree."},
{"flag": "BX-002", "handled": true, "note": "Claim 2 and BD-002; the number explains what testers could not."},
{"flag": "BX-003", "handled": true, "note": "Nobody mentioned Bone Reaper, so it is the unheard entry."}
],
"next_steps": [
"Ship Void Blade crit_chance 14 and Warhammer cost 240, then re-test the same shop.",
"Ask next session specifically about Bone Reaper, which nobody named.",
"Add a range falloff term if range is meant to be a real axis."
],
"playtest": { "session": {"testers": "four", "build": "0.7.2", "focus": "…"},
"claims": [ … four entries, one per point … ],
"unheard": [{"entry": "Bone Reaper", "why": "…"}],
"build_notes": [ … ], "note": "…" }
}
Three things to take from this one. The fourth claim comes back "support": "no data"
with an empty entries array, because the shield rune is not a row in this table —
that is the correct answer, not a failure. unheard is where a thin session still pays
for itself: nobody mentioned Bone Reaper, which is what dead content looks like from inside a test.
And verdict is partly confirmed, two words with a space — validate it
against the playtest list, not against a slug.
What the free reader gives you
Before any lane is run, the web app reads the table in the browser. That reader is the whole of
what this app does for free: no account, no token, no job, nothing uploaded. It parses CSV, TSV,
semicolon- and pipe-delimited text, a markdown table, a JSON array of entries or a JSON object
keyed by entry name; it reads numbers the way designers write them, so 1,250,
42%, $30 and 1.5x all arrive as numbers. It then identifies
the name, tier, cost, rarity, drop-weight and income columns by header, treats every other numeric
column as a stat, and works out which of those stats are lower-is-better before scoring anything.
Three things come out of it, and all three are free:
- The
factsobject — the power table, the per-stat summary with its outliers, the full Pareto dominance comparison, the tier-over-tier progression with its shape, the ordinary least-squares fit of cost on power with every residual, the drop probabilities and pull counts, and the sink-against-faucet totals. This is what you put in thefactsfield. - The
BXflags — every problem the arithmetic found, each with an id, a level, a stable code, a title and a detail. This is the list the review is graded against, and it is the only externally checkable part of the whole contract. - The exports — the findings as CSV, the measured power table as CSV, a Markdown report and the raw result JSON.
If you drive this API yourself, produce your own equivalent facts and send them.
The lanes are written to reconcile ground truth, not to rediscover it: measured numbers that arrive
in facts are quoted verbatim, and every BX-nnn you send comes back once in
coverage_check. Without them the reply is still a review, but nothing anchors it —
there is no list of things the model was obliged to answer for, so a plausible answer and a correct
one look the same from outside. If you are calling from a browser, you can get the real one for
nothing: load balancescan.js and send
BalanceScan.facts(BalanceScan.scan(tableText)).
The reader is deterministic and the lane is not. Keep the split: parsing, role detection, power, efficiency, dominance, the fit, the drop arithmetic and the flags belong to your parser; judgement, retunes, repricing, claim adjudication and ranking belong to the lane. The most expensive mistake available on this API is to ask the model to do arithmetic you could have done exactly.
Rate limits, metering and good manners
- Poll no faster than once a second, and back off on
429. A run takes tens of seconds, not hundreds of milliseconds; a tight loop buys you nothing but a rate limit. - Reuse one idempotency key per
(task, table, notes, attempt). A retry with the same key returns the original job rather than billing twice. /estimateis free and creates no job — call it before every run, on the lane you are about to run, and compare it against/me. Every lane has its own hold.- Metering is per settled job.
hold_creditsis reserved at submit andcharged_creditsis what you actually pay; the difference is released. Report againstcharged_credits, budget againsthold_credits. - Guests can
/meand/estimatebut not/run. This app does not sponsor guest usage, sosponsor_enabledisfalseand a guest run is a403. - Send the input object as the body. Not
{"input": {…}}, and never anX-App-Slugheader. - Clip
tableon whole-row boundaries, always keep the header row, and mark the cut so the lane knows it is reading a slice. - Send
factswhen you have them, and check everyBX-nnnyou sent came back exactly once incoverage_check. - Validate the verdict against the lane's own token list, and the check names against the lane's own list, before you render anything.
- Never put a token in client-side source or a repository. Read it from the environment.
Attribution
Balance Desk is a derived work built on @donchitos/balance-check (the audit lane),
@thedivergentai/godot-economy-system (the economy lane) and
@donchitos/playtest-report (the playtest lane). Godot is a project of the Godot
Foundation; every game, engine and storefront named in a table you paste remains the property of
its owner. This app is not affiliated with or endorsed by any of them.