← Battle Simulator / API
Get a token

Drive Battle Simulator from your own code

Everything the web app does goes through one public surface. Base URL:

https://api.skillsafe.ai/v1/app-api

Every request carries Authorization: Bearer <token> and Content-Type: application/json. Every response is a JSON envelope: {"data": …} on success and {"error": {"code": "…", "message": "…"}} on failure. Read error.code, not the HTTP status alone.

Battle Simulator is a single-contract app. There is no task field and no lane router: one input object covers a first adjudication, a rematch and a challenge, distinguished by shape. The interesting part of the contract is not the transport — it is that the numbers travel in the request. The client ships a curated stat table of 46 contenders and passes the two relevant fact blocks into the run as fixed input, so the model reasons rather than improvising arithmetic. If you call this API directly, you supply that table yourself, and everything the verdict is allowed to state is whatever you put in it.

There is no X-App-Slug header, and the run body is not wrapped. The body of /estimate, /run and /run-stream is the input object itself. Wrapping it as {"input": {…}} does not fail loudly — it returns 200 with a job that runs, because the wrapper becomes a single opaque field and every one of your fields is hidden from the model. You get a confident verdict about nothing. Send the object flat.

Errors

CodeMeaningWhat to do
UNAUTHORIZEDMissing, expired, or a token minted for a different app.Mint a guest token or sign in again. A cold 401 from /me before any token exists is normal, not a fault.
INSUFFICIENT_CREDITSBalance below min_credits for this run.Top up. Call /estimate first — it is free and returns both the hold and the minimum.
VALIDATION_ERRORThe input object failed validation at the run boundary.Check error.details. The overwhelmingly common cause is an {"input": …} wrapper, a sides array that is not exactly two entries, or a rematch/challenge sent with prior: null.
RATE_LIMITEDToo many requests from this subject.Back off and retry. Do not tight-loop the job poller; two seconds between polls is what the web app uses.
NOT_FOUNDThe job id in /jobs/{id} does not exist, or belongs to another subject.Re-read data.job_id from the /run reply. A job id is scoped to the token that created it, so a fresh guest token cannot poll a previous one's job.
INTERNALThe run started and did not complete.Retry with the same Idempotency-Key so a partial charge is not doubled. If a stream died mid-object, keep the bytes: a truncated verdict is often 90% complete and worth repairing rather than discarding.

The shape field

Read this before the rest of the input object, because it decides which of the other fields are required and what the reply contains. Exactly one of three values, and it is not optional:

ShapeWhat it doesAlso requiredReply adds
"adjudicate" The first verdict for a pair under a setting. Nothing beyond the common fields.
"rematch" The same pair under changed conditions. The reply is written against the previous one, and is expected to say honestly when the change did not move the verdict. prior — a digest of the previous reply. Sending prior: null is a VALIDATION_ERROR; the shape has nothing to be a rematch of. delta
"challenge" You dispute a point in a verdict. It concedes, partly concedes, or holds its position and says why. An objection that is a preference rather than an error is not supposed to move it. prior and a non-empty challenge_text. The web app refuses to submit an empty challenge rather than sending one and letting the model guess at the complaint. delta, including delta.challenge_answer

rematch and challenge are the same lane and the same price band as adjudicate. Estimate each one separately anyway: a body carrying a prior is structurally larger, and the hold is computed from what you actually send.

The input object

FieldTypeRequiredWhat it is
shapestringyes"adjudicate", "rematch" or "challenge", as above.
sidesarrayyesExactly two fact blocks, side "A" first. This is the stat table. See below.
conditionsobjectyesFive axes, each {"id","label","note"}: terrain, visibility, prep, victory, scale. The note is what that option means for a matchup, not decoration — it is the sentence the verdict reasons against.
condition_profileobjectyes{"A": …, "B": …}. The conditions already resolved against each contender's traits. See below.
setting_sentencestringyesOne sentence describing the whole setting, so the reply can restate it in conditions_read without reassembling five ids.
extrastringnoA free-text note refining the setting. It refines; it does not override an axis. The web app clips anything over 1,200 characters from the middle, keeps both ends, and marks the cut in-band.
extra_clippedbooleannoWhether the note above was clipped.
guard_rulesarrayno{"id","tier","binding_note"}. Content rules the client resolved as applying to this matchup. tier is "bind" (the rule constrains the reply) or "flag". A "block"-tier finding means the web app never sent the request at all.
priorobjecton rematch/challengeA digest of the previous reply: title, conditions, evidence_basis, decisive_factor, verdict, brings (per side: side, contender, assets[], liabilities[]) and flips (the change strings). Send null only when shape is "adjudicate".
challenge_textstringon challengeThe specific objection, in the caller's own words.

A side fact block

Two of these, in order, side "A" then side "B". Everything in stats is what the verdict is permitted to state; everything absent from it is what the verdict must not invent. instruction is carried in-band deliberately, so the constraint travels with the facts rather than living only in a system prompt you cannot see.

{
  "side": "A",
  "name": "Grizzly bear",
  "in_table": true,
  "id": "grizzly-bear",
  "category": "Animal",
  "era": null,
  "summary": "A heavy, durable omnivore built around forelimb strength and a bite that does not need to be exceptional to be sufficient.",
  "stats": [
    {
      "id": "mass",
      "label": "Adult male body mass",
      "value": "180-360 kg inland; coastal and Kodiak bears 300-600 kg",
      "kind": "typical_range",
      "provenance": "Inland grizzly and coastal brown bear populations differ enormously; quoting a Kodiak figure for an inland grizzly is a common error."
    },
    {
      "id": "bite",
      "label": "Bite force",
      "value": "about 1,100 N at the canines in a published study of a captive adult",
      "kind": "measured",
      "provenance": "One published bite-force measurement; single-animal studies do not generalise well and this figure should be read as an order of magnitude."
    }
  ],
  "traits": ["terrestrial", "bite", "claw", "crush_mass", "armour_hide", "sprint", "strong_swimmer", "climber", "individual"],
  "behaviour": [
    "Documented behaviour: grizzlies routinely displace wolf packs from carcasses, absorbing bites rather than avoiding them."
  ],
  "unknowns": [
    "No measurement exists of a bear's striking force with the forelimb; figures quoted for it are extrapolations."
  ],
  "instruction": "Every figure you state for this contender must appear verbatim in the `value` of one of the stats above, and the stat id must be named in `grounded_in`. Do not convert, average, or round a stated range into a single number."
}

A contender you have no figures for is sent with in_table: false, empty stats, traits, behaviour and unknowns, and an instruction that tells the model to state no measurement of any kind for that side and to say plainly in the output that its reasoning is unsourced. That is a supported case, not a degraded one: it is how you get an honest qualitative read instead of a fabricated stat sheet.

{
  "side": "B",
  "name": "A hippo I saw once",
  "in_table": false,
  "category": "unknown",
  "stats": [], "traits": [], "behaviour": [], "unknowns": [],
  "instruction": "This contender is NOT in the stat table. State no masses, speeds, forces, dimensions, dates or any other measurement for it. Reason about it qualitatively, from widely known general character, and say plainly in the output that the reasoning for this side is unsourced."
}

Provenance: stats[].kind

Every figure declares how it got into the table. These are not labels for display; they are the difference between a number you can argue with and a number you cannot.

kindWhat it asserts
measuredA direct measurement of real specimens or hardware, repeatable in principle. Rare, and the strongest label available.
typical_rangeA published field range for a population. The spread is the fact; collapsing it to a midpoint would be a fabrication, which is why the instruction forbids averaging one.
design_specA published specification for a machine — what the maker or operator states. Not an independent measurement, and frequently optimistic.
historical_estimateA scholarly reconstruction. Contested by construction, and often a range across mutually incompatible sources.
fictional_canonStated inside a work of fiction. Not a measurement of anything: true within that text only. Grading it as an observation is the same category error as inventing a bite force, pointed the other way.

A condition profile

One per side, under condition_profile.A and condition_profile.B. This is the setting already resolved against that contender's traits, so changing the terrain changes the input rather than only the wording. amplified and nullified entries carry the axis that caused them and a because; contested entries are capabilities the setting pulls both ways.

{
  "side": "A",
  "name": "Grizzly bear",
  "amplified": [
    {"trait": "bite", "reads": "has a serious bite", "axis": "confined",
     "because": "Confined space: disengagement is impossible, which converts every fight into an exchange and rewards whatever survives one."}
  ],
  "nullified": [
    {"trait": "sprint", "reads": "is fast in a burst", "axis": "confined",
     "because": "Confined space: there is no room to build speed and no line of retreat to use it on."}
  ],
  "contested": [
    {"trait": "climber", "reads": "can climb"}
  ],
  "disqualified": [],
  "cannot_participate": false,
  "cannot_lose_under_condition": false
}

The last two booleans mean opposite things and must not be collapsed. cannot_participate is absence: a gorilla cannot swim, so there is no matchup in open water to adjudicate. cannot_lose_under_condition is immunity: a tornado cannot be driven off, because there is nothing in it to persuade — the victory condition is unsatisfiable, not the contender missing. A disqualified entry carries kind of "absent" or "immune" accordingly, plus axis, because and says.

Worked input: shape: "adjudicate"

The whole body, with the stat rows trimmed to two per side for length. This is what goes to /estimate, /run and /run-stream unchanged and unwrapped.

{
  "shape": "adjudicate",
  "sides": [
    {
      "side": "A", "name": "Grizzly bear", "in_table": true, "id": "grizzly-bear",
      "category": "Animal", "era": null,
      "summary": "A heavy, durable omnivore built around forelimb strength and a bite that does not need to be exceptional to be sufficient.",
      "stats": [
        {"id": "mass", "label": "Adult male body mass", "value": "180-360 kg inland; coastal and Kodiak bears 300-600 kg", "kind": "typical_range", "provenance": "Inland grizzly and coastal brown bear populations differ enormously."},
        {"id": "bite", "label": "Bite force", "value": "about 1,100 N at the canines in a published study of a captive adult", "kind": "measured", "provenance": "One published bite-force measurement; single-animal studies do not generalise well."}
      ],
      "traits": ["terrestrial", "bite", "claw", "crush_mass", "armour_hide", "sprint", "individual"],
      "behaviour": ["Documented behaviour: grizzlies routinely displace wolf packs from carcasses, absorbing bites rather than avoiding them."],
      "unknowns": ["No measurement exists of a bear's striking force with the forelimb."],
      "instruction": "Every figure you state for this contender must appear verbatim in the `value` of one of the stats above, and the stat id must be named in `grounded_in`. Do not convert, average, or round a stated range into a single number."
    },
    {
      "side": "B", "name": "Silverback gorilla", "in_table": true, "id": "silverback-gorilla",
      "category": "Animal", "era": null,
      "summary": "The strongest primate by a wide margin, and a herbivore whose entire threat repertoire is built to end a fight without one.",
      "stats": [
        {"id": "mass", "label": "Adult male body mass", "value": "140-200 kg", "kind": "typical_range", "provenance": "Published adult male ranges; wild males are lighter than captive."},
        {"id": "arm-span", "label": "Arm span", "value": "2.0-2.6 m", "kind": "typical_range", "provenance": "Adult male arm span, substantially exceeding standing height."}
      ],
      "traits": ["terrestrial", "bite", "crush_mass", "climber", "non_swimmer", "individual", "tool_user"],
      "behaviour": ["Documented behaviour: conflict is overwhelmingly display and resolves without contact."],
      "unknowns": ["No live bite-force measurement exists for a gorilla."],
      "instruction": "Every figure you state for this contender must appear verbatim in the `value` of one of the stats above, and the stat id must be named in `grounded_in`. Do not convert, average, or round a stated range into a single number."
    }
  ],
  "conditions": {
    "terrain":    {"id": "confined",     "label": "Confined space",              "note": "Disengagement is impossible, which converts every fight into an exchange and rewards whatever survives one."},
    "visibility": {"id": "daylight",     "label": "Full daylight",               "note": "Everything that depends on being seen works, and everything that depends on not being seen does not."},
    "prep":       {"id": "none",         "label": "No warning",                  "note": "Nothing that has to be organised, loaded, crewed or planned is available."},
    "victory":    {"id": "incapacitate", "label": "Incapacitate the other side", "note": "The most demanding condition and the one that most favours raw damage over everything else."},
    "scale":      {"id": "single",       "label": "One of each",                 "note": "Everything social or doctrinal is stripped out, which is a much larger handicap for some contenders than others."}
  },
  "condition_profile": {
    "A": {"side": "A", "name": "Grizzly bear", "amplified": [{"trait": "bite", "reads": "has a serious bite", "axis": "confined", "because": "Confined space: disengagement is impossible."}], "nullified": [{"trait": "sprint", "reads": "is fast in a burst", "axis": "confined", "because": "Confined space: no room to build speed."}], "contested": [], "disqualified": [], "cannot_participate": false, "cannot_lose_under_condition": false},
    "B": {"side": "B", "name": "Silverback gorilla", "amplified": [{"trait": "crush_mass", "reads": "brings sheer mass", "axis": "confined", "because": "Confined space: every exchange happens at contact distance."}], "nullified": [], "contested": [{"trait": "climber", "reads": "can climb"}], "disqualified": [], "cannot_participate": false, "cannot_lose_under_condition": false}
  },
  "setting_sentence": "One of each, on confined space in full daylight. Both sides arrive with no knowledge of the other and no time. The engagement ends when one side can no longer continue.",
  "extra": "",
  "extra_clipped": false
}

Worked input: shape: "rematch"

Identical to the above except that shape is "rematch", the conditions, condition_profile and setting_sentence reflect the new setting, and prior carries the digest of the reply you are rematching. Send the fact blocks again in full — prior is a summary of the last verdict, not of the last input, and it does not re-supply the stats.

{
  "shape": "rematch",
  "sides": [ /* the same two fact blocks, in full */ ],
  "conditions": {
    "terrain":    {"id": "deep_water",   "label": "Open water",                  "note": "Anything that cannot swim is not in this fight."},
    "visibility": {"id": "daylight",     "label": "Full daylight",               "note": "Everything that depends on being seen works."},
    "prep":       {"id": "none",         "label": "No warning",                  "note": "Nothing that has to be organised is available."},
    "victory":    {"id": "incapacitate", "label": "Incapacitate the other side", "note": "Favours raw damage over everything else."},
    "scale":      {"id": "single",       "label": "One of each",                 "note": "Everything social or doctrinal is stripped out."}
  },
  "condition_profile": {
    "A": {"side": "A", "name": "Grizzly bear", "amplified": [], "nullified": [], "contested": [], "disqualified": [], "cannot_participate": false, "cannot_lose_under_condition": false},
    "B": {"side": "B", "name": "Silverback gorilla", "amplified": [], "nullified": [],
      "contested": [],
      "disqualified": [
        {"trait": "non_swimmer", "reads": "cannot swim", "axis": "terrain", "kind": "absent",
         "because": "Open water: out of depth, with no bottom to stand on and no shore in reach.",
         "says": "A silverback gorilla cannot be present in this setting at all."}
      ],
      "cannot_participate": true, "cannot_lose_under_condition": false}
  },
  "setting_sentence": "One of each, on open water in full daylight. Both sides arrive with no knowledge of the other and no time. The engagement ends when one side can no longer continue.",
  "extra": "",
  "extra_clipped": false,
  "prior": {
    "title": "Grizzly bear versus silverback gorilla, in a corridor",
    "conditions": "Terrain: Confined space · Light: Full daylight · Preparation: No warning · Victory condition: Incapacitate the other side · Scale: One of each",
    "evidence_basis": "mixed",
    "decisive_factor": {"claim": "Mass and a bite that closes on bone", "mechanism": "…", "grounded_in": "A.mass", "confidence": "medium"},
    "verdict": {"favoured": "A", "margin": "clear", "in_words": "…", "how_often": "…"},
    "brings": [
      {"side": "A", "contender": "Grizzly bear", "assets": ["Heavier across the published ranges"], "liabilities": ["Nothing in the table measures a forelimb strike"]},
      {"side": "B", "contender": "Silverback gorilla", "assets": ["Reach beyond standing height"], "liabilities": ["Threat display has nowhere to work"]}
    ],
    "flips": ["Move it to open water", "Change the victory condition to drive-off"]
  }
}

Worked input: shape: "challenge"

Same as rematch — the conditions normally stay unchanged, since you are disputing the reading rather than the setting — plus a non-empty challenge_text.

{
  "shape": "challenge",
  "sides": [ /* the same two fact blocks, in full */ ],
  "conditions": { /* unchanged from the verdict being challenged */ },
  "condition_profile": { /* unchanged */ },
  "setting_sentence": "One of each, on confined space in full daylight. Both sides arrive with no knowledge of the other and no time. The engagement ends when one side can no longer continue.",
  "extra": "",
  "extra_clipped": false,
  "prior": { /* the same digest as above */ },
  "challenge_text": "You leaned on the bite-force figure, but the table labels it as one measurement of one captive animal and its own provenance note says it should be read as an order of magnitude. That cannot carry a clear margin."
}

The output contract

One JSON object, no prose and no code fence. Top-level keys, all required: title, matchup, conditions_read, evidence_basis, sides, decisive_factor, strongest_counter, likely_course, verdict, flips, not_measured, unknowns — plus delta on the two follow-up shapes.

{
  "title": "Grizzly bear versus silverback gorilla, in a corridor",
  "matchup": {"a": "Grizzly bear", "b": "Silverback gorilla"},
  "conditions_read": "A single animal each, in a space neither can leave, in full daylight, with neither having any warning, ending only when one cannot continue.",
  "evidence_basis": "mixed",
  "sides": [
    {
      "side": "A",
      "contender": "Grizzly bear",
      "brings": [
        {"asset": "Heavier across the whole published range", "grounded_in": "A.mass",
         "why_it_matters": "In a space with no room to circle, the exchange is decided at contact and mass sets what each contact costs."},
        {"asset": "Willing to absorb damage rather than avoid it", "grounded_in": "behaviour",
         "why_it_matters": "Displacing wolves from a carcass is a documented habit of taking bites in order to keep a position."}
      ],
      "liabilities": [
        {"issue": "The bite figure is a single captive measurement", "grounded_in": "A.bite",
         "why_it_matters": "Its own provenance note says to read it as an order of magnitude, so it cannot carry a fine margin."}
      ],
      "condition_effects": {
        "amplified": ["A serious bite, because there is no disengaging from an exchange"],
        "nullified": ["Burst speed, because there is nowhere to build it"],
        "contested": ["Climbing, which the walls both invite and deny"]
      }
    },
    {
      "side": "B",
      "contender": "Silverback gorilla",
      "brings": [
        {"asset": "Reach well beyond standing height", "grounded_in": "B.arm-span",
         "why_it_matters": "Arm span exceeding height means first contact can happen before the other side closes."}
      ],
      "liabilities": [
        {"issue": "The entire threat repertoire is built to avoid a fight", "grounded_in": "behaviour",
         "why_it_matters": "Display resolves conflict by giving the other party somewhere to go, and this setting has removed that."}
      ],
      "condition_effects": {"amplified": ["Sheer mass at contact distance"], "nullified": [], "contested": []}
    }
  ],
  "decisive_factor": {
    "claim": "The victory condition removes the gorilla's actual win condition",
    "mechanism": "Its conflict behaviour is built on deterrence, and a fight that ends only when one side cannot continue offers nothing to deter.",
    "grounded_in": "setting",
    "confidence": "medium"
  },
  "strongest_counter": {
    "claim": "Arm span exceeding standing height means the gorilla can reach first, and the published mass ranges overlap at their edges, so the size gap is not the gulf it is usually assumed to be.",
    "grounded_in": ["B.arm-span", "A.mass"],
    "why_it_fails": "Reaching first only decides a fight that ends at first contact, and this victory condition ends only when one side cannot continue.",
    "how_close": "substantial"
  },
  "likely_course": [
    {"phase": "Opening", "what_happens": "…", "hinges_on": "Whether first contact happens at arm's length or at body length."}
  ],
  "verdict": {
    "favoured": "A", "margin": "clear",
    "in_words": "…",
    "how_often": "Most of the time, but not overwhelmingly."
  },
  "flips": [
    {"change": "Change the victory condition to driving the other side off", "axis": "victory",
     "effect": "Deterrence becomes a way to win, which is what the gorilla is actually built for.",
     "would_favour": "B"}
  ],
  "not_measured": ["Neither side has a measured striking force; the whole exchange model is reasoning."],
  "unknowns": ["No live bite-force measurement exists for a gorilla, so the two sides are not comparable on that axis at all."]
}

strongest_counter, and why it is required

A matchup ships two stat blocks, and a fluent argument can be built out of either one. An adjudicator that can argue both sides to the same confidence is not adjudicating — it is generating advocacy on request, and the tell is that it never has to say what the other case was. So the reply must state the best case for the side it did not favour, at full strength, and then say why that case does not carry under these conditions. It is required in every reply, including a refusal: a verdict that declines to answer still has to show it understood what it was declining.

"strongest_counter": {
  "claim": "The best case for the side the verdict did NOT favour, argued at full strength rather than set up to fall over.",
  "grounded_in": ["B.arm-span", "A.mass"],
  "why_it_fails": "The mechanism by which it does not carry under these conditions.",
  "how_close": "substantial"
}

There is a calibration rule between this field and the verdict, and it is worth checking yourself because it is exactly what a confident model gets wrong: if how_close is decisive or substantial, then decisive_factor.confidence must not be high and verdict.margin must not be decisive. A reply that concedes a substantial counter and still calls the result decisive has contradicted itself in two adjacent fields, and a caller driving the API directly is the only one who will notice.

Write your parser to tolerate a truncated reply rather than discarding it. The web app walks the fragment, closes what is open, drops a dangling key rather than inventing a value for it, and renders the sections that arrived with an honest note about what did not. Step 7 below is that logic in miniature.

The honesty contract

This is the part of the app that is not a wrapper around a model call, and it is the part you do not get for free over the API. The web app does three things after every reply, and a caller driving the API directly gets none of them unless they implement them.

1. grounded_in — the declaration

Every brings and liabilities entry, and decisive_factor and strongest_counter themselves, must declare how it knows. The value is either a single token or an array of tokens — ["A.mass", "B.mass"] — because a claim that rests on both sides at once should say so rather than picking one and hiding the other. Every token comes from a closed set:

ValueMeans
A.<statId> / B.<statId>A stat you supplied, named by side and id — A.mass, B.arm-span. The only kind of grounding that licenses a figure.
A.behaviour / B.behaviourOne of the behaviour[] strings you supplied for that specific side. Preferred over the bare form when only one side's behaviour is doing the work.
behaviourDocumented behaviour without a side prefix — accepted, and the right choice when the claim rests on both sides' behaviour together.
settingThe conditions or the resolved condition profile.
reasonedThe model's own inference. Legitimate — most of a good verdict is reasoning — but a reasoned claim may not contain a figure. That is the whole point of the token: it is the model saying "I am not measuring here."

So the first check is cheap and worth doing: build the set of valid tokens from the input (reasoned, behaviour, setting, A.behaviour, B.behaviour, plus <side>.<statId> for every stat of every in_table side), then normalise each grounded_in to a list — a bare string is a one-element list — and flag any claim with an empty list or a token outside the set. A claim that declines to say how it knows is the failure mode this contract exists to catch.

2. The figure audit — what the client actually does

The declaration is a promise. The audit checks it. Every number in the reply is compared against every number you supplied, and a figure with no source is surfaced to the reader — not silently accepted, and not silently deleted. Deleting it would hide the failure; accepting it would make the app the exact thing it exists not to be.

To reimplement it:

  1. Build the allowed set from the input. Walk every stats[].value string of every in_table side and extract every number followed by a unit. Normalise each to a base unit within its family (mass, length, speed, force, power, pressure, time, temperature, current, voltage, angle) so comparison is unit-agnostic. Record side, statId and kind alongside each.
  2. Handle ranges first, not last. "180-360 kg" is one unit shared across two numbers. Scan for the range pattern before the single-figure pattern, put both endpoints in the set, and additionally record the interval [180, 360]. A plain left-to-right scan sees only 360 and will report a verdict that correctly quoted the lower end of a published range as having fabricated it.
  3. Extract every figure from the whole reply. Walk every string leaf at any depth, not the fields that seem likely — a rule scoped to one field is a rule that moves the problem into the field beside it. Keep about seventy characters of context either side so a finding can quote the sentence rather than a bare number.
  4. Cover each figure against the allowed set within its unit family, with a small tolerance (the client uses 2%). An interval covers anything inside it. Distinguish two outcomes: matched with the same unit written is a quote; matched only after converting units is a separate finding class. Converting "about 600 kg" into "roughly 1,300 lb" is arithmetic the table did not authorise and rounding the model chose itself, so it is reported rather than passed quietly.
  5. Exclude percentages, and require a unit. "Wins about seven times in ten" is the probabilistic judgement the model is supposed to be producing; flagging it would fill the panel with noise and teach the reader to skip it. A bare number with no unit is not a figure and is never audited, which is what keeps ordinal prose out of the results.
  6. Be narrow on purpose. A unit outside your table yields no figure and therefore no finding. A false accusation of fabrication costs more than a missed one.
  7. Report, do not rewrite. Show the unsourced figure with the sentence it appeared in. What the audit tells a reader is not that a number is wrong — it is that they cannot check it against anything, which is the true statement and the one that lets them decide what the verdict is worth.

3. The untabled side

When a side was sent with in_table: false, its instruction forbids stating any measurement at all. So any figure that appears attributable to that side is a finding by construction, regardless of the allowed set — there is nothing it could legitimately have come from. Check evidence_basis too: a reply that reads documented when one side had no stats is itself a contract violation.

The stat table is a curated subset of the world, not an authority. A figure it does not contain may still be perfectly correct. Neither the audit nor this API can tell you whether a number is true; both can tell you whether it is checkable, which is the only claim either is entitled to make.

1. A tiny client

Three things, once, so the rest of the page can be short: the base URL, the two headers every request carries, and the envelope unwrap. Check error.code before you touch data — a failure is a shaped object, not an exception your HTTP library will raise for you.

2. Get a token

Every call needs one. A guest token is minted on demand and is enough for /me and /estimate; adjudicating is metered and needs a personal token, which comes from signing in. The tokens page shows the token this browser already holds, copies it, copies a ready-made shell export, and mints a fresh guest token — no developer console needed. Replace YOUR_TOKEN above with what it gives you.

To mint a guest token from code instead, POST /guest with the app slug. It takes no Authorization header, and it returns token and guest_id. Keep the guest_id: it is what lets a later sign-in migrate the guest wallet rather than stranding its balance.

3. Check the session and the balance

GET /me returns exactly three fields: subject_type, subject_id and credits. There is no email and no display name, so the signed-in test is subject_type === "user" — anything else is a guest. A cold UNAUTHORIZED here, before any token has been minted, is the normal first response and not a fault to report.

4. Price it before you run it

POST /estimate is free, starts no job and charges nothing. It returns hold_credits, min_credits, model and model_alias. hold_credits is a reservation, not the price — it is computed against the full output cap, and what is actually charged is usually much lower and is reported after the run. Compare your balance against min_credits, not against the hold.

/estimate performs no validation on the body whatsoever. A bare string, null, an empty array and the number 42 all come back successful, with a correct model binding and the same hold as a well-formed input. There is no failure signal at all — no throw, no 4xx, just a plausible number. So a green estimate proves nothing about your input shape, and the only place that can be checked is your side of the wire. Assert that the body is a plain object with exactly two sides and a valid shape before every spend, and unit-test that assertion by sabotage rather than trusting a code review.

5. Run it and poll

POST /run submits and returns a job. Always send an Idempotency-Key: a retried request with the same key is the same run, so a network blip cannot bill you twice. The web app's key is battle-simulator:<shape>:<input-hash>:a<attempt> — the shape is in the key because a rematch and an adjudication over the same pair are two different runs, and the attempt counter is what lets a deliberate retry through while a duplicate submit is absorbed.

Poll GET /jobs/{id} every couple of seconds until status is succeeded or failed. The verdict is the JSON string at output.output, so it needs a second parse — see step 7.

6. Or stream it

POST /run-stream is the same run, same body, same Idempotency-Key, delivered as server-sent events. Frames are separated by a blank line and carry a named event:

event: job
data: {"job_id":"...","status":"running"}

event: delta
data: {"text":"{\"title\":\"Grizzly bear versus"}

event: done
data: {"job_id":"...","status":"succeeded","charged_credits":41,"output":{"output":"…"}}

Accumulate the text of every delta frame; that concatenation is the verdict. The done frame carries charged_credits — the real price, which is normally well below the hold — and output.output, which is the same string again for callers that did not buffer. An error frame is terminal and carries code, message and job_id. A pending frame in place of done means the run is continuing out of band; poll the job id.

On an idempotent replay the server may answer with plain JSON instead of text/event-stream. Check the content-type before you start reading lines, and fall back to the envelope path if it is not an event stream.

7. Parse the reply

The contract is one bare JSON object, and it mostly arrives that way. Write the parser for the three cases where it does not: a code fence around it, a sentence of preamble before it, and a stream that stopped mid-object. Four steps:

  1. Strip a leading ```json or ``` and a trailing ```.
  2. Find the first { and scan forward to its matching close brace, tracking string state and backslash escapes so a brace inside a quoted string does not throw off the depth count. Everything before and after is discarded.
  3. Parse. If the object never closed, the stream was cut: close what is open, drop a trailing comma and any dangling "key": with no value — do not invent a value for it — and re-parse. If that still fails, trim back to the last structural comma and try again. Every prefix of a JSON document has a valid object somewhere behind it, and a verdict that is 90% complete is worth rendering with an honest note rather than discarding.
  4. Validate the required keys and mark the missing ones, rather than assuming they are there.

Required top-level keys, all twelve: title, matchup, conditions_read, evidence_basis, sides, decisive_factor, strongest_counter, likely_course, verdict, flips, not_measured, unknowns — plus delta when shape was rematch or challenge. Check the enumerations too: an evidence_basis or a verdict.margin outside its closed set is a malformed reply wearing a plausible face. And check the calibration rule while you are there — a strongest_counter.how_close of decisive or substantial alongside a decisive margin or a high confidence is a reply contradicting itself.