← Balance Desk / API
Token panel

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

CodeHTTPWhat it meansWhat to do
UNAUTHORIZED401Missing, malformed or expired token.Mint a new one from the token panel.
FORBIDDEN403The 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_CREDITS402Balance is under min_credits for this run.Compare /me against /estimate before running.
VALIDATION_ERROR400The input object is the wrong shape.Check error.details; task and table are required.
RATE_LIMITED429Too many requests.Back off; do not tight-loop a poll.
NOT_FOUND404No such route, or no such job_id for this subject.Check the path and that the job was created by this token.
INTERNAL500Something 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.

taskLaneAnswersLane blockSource skill
auditAuditWhat is wrong with this table before players find it?audit{}@donchitos/balance-check
economyEconomyDoes the money work — the ladder, the sinks, the grind, the drop table?economy{}@thedivergentai/godot-economy-system
playtestPlaytestWhich 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.

FieldTypeRequiredNotes
taskstringyesOne 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.
tablestringyesThe 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.
genrestringnoroguelike, 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.
intentstringnoFree 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.
notesstringnoRaw 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.
carryoverstringnoA 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.
factsobjectnoThe 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

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.

KeyTypeWhat it carries
readablebooleanWhether 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.
formatstringWhat it was read as: csv, tsv, delimited, markdown, json or unknown.
rowsnumberHow many data rows were parsed, header excluded.
columnsstring[]The header names exactly as they appeared. The lane quotes these, so send them verbatim rather than normalised.
rolesobjectWhich column got which job. See the role table below.
summaryobjectentryCount, statCount, scoredStatCount, tierCount, hasCost, hasWeight, hasIncome, medianPower, strongest, weakest, ignoredColumns. The shape of the table in one object.
stat_summaryobject[]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_tableobject[]One entry per row: name, tier, cost, power, efficiency, efficiency_z. This is the spine of every lane's argument.
dominanceobject or nulldominated[{name, by[], count}], dominating[{name, dominates}] and comparable (how many entries had every stat populated). null when there is nothing to compare.
progressionobject or nullbyTier[{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.
pricingobject or nullThe 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.
dropsobject or nullscale (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.
economyobject or nullsinkCount, faucetCount, sinkTotal, faucetTotal, sinkFaucetRatio, costMin, costMax, costMedian, costSpread, grind[{name, cost, income, units}], grindCount, grindMedian. null without a cost or an income column.
flagsobject[]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

KeyTypeMeaning
namestring or nullThe column holding the entry name. Null means findings can only refer to row numbers, which is a flag in itself.
tierstring or nullThe column holding the tier, level, rank, wave or act. Drives the whole progression block.
coststring or nullThe column holding the price. Drives efficiency, the pricing fit and the sink totals.
raritystring or nullThe column holding a rarity or quality label. Checked against the drop odds for inversions.
weightstring or nullThe column holding a drop weight, rate, chance or probability.
incomestring or nullThe column holding a payout, reward or yield. Without it there is no faucet number.
statsstring[]Every other numeric column. These are what power is averaged over.
lower_is_betterstring[]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.
ignoredstring[]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

taskverdict tokensKeyShape
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.

Lanechecks[].name, in order
audittable shape, stat coverage, power spread, cost efficiency, dominated entries, tier progression, outliers, degenerate strategies
economyprice ladder, power-to-price fit, currency sinks, currency faucets, grind time, drop table integrity, rarity coherence, paywall pressure
playtestnotes 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:

  1. Same set. The multiset of coverage_check[].flag equals the multiset of facts.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.
  2. 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.
  3. handled: false carries a reason. A false with an empty or hand-waving note is worse than a missing entry, because it looks answered. The honest false — "that column is locked by intent", "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:

Keep the token out of your source. Read it from the environment at runtime and never commit it.

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.

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.

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:

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.

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.

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:

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.

7. Parse the result

Five moves, in this order, whichever route you took:

  1. 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 of output.output normally works. Be tolerant anyway: slice from the first { to the last } before parsing, so a stray ```json fence or a leading newline is not an outage.
  2. Route on lane, not on the task you sent. An unrecognised task is answered by the nearest lane, and that lane is what lane says. If lane is not the task you sent, that is not an error — but it is worth logging, because it means your task value did not match one of the three ids and something upstream is probably wrong.
  3. Read that lane's block and nothing else. audit, economy, playtest — exactly one is present, and it is named after the lane, so result[result.lane] gets it.
  4. Check coverage_check against the flags you sent. Every BX-nnn in facts.flags appears exactly once. A missing id, a duplicate, or an id you never sent are all signals to distrust the reply rather than render it.
  5. 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, overview and the first checks arrive early and are worth showing; throwing the whole run away because the last two retune rows never landed wastes a credit you have already spent.

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:

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

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.