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
| Code | Meaning | What to do |
|---|---|---|
UNAUTHORIZED | Missing, 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_CREDITS | Balance below min_credits for this run. | Top up. Call /estimate first — it is free and returns both the hold and the minimum. |
VALIDATION_ERROR | The 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_LIMITED | Too 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_FOUND | The 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. |
INTERNAL | The 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:
| Shape | What it does | Also required | Reply 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
| Field | Type | Required | What it is |
|---|---|---|---|
shape | string | yes | "adjudicate", "rematch" or "challenge", as above. |
sides | array | yes | Exactly two fact blocks, side "A" first. This is the stat table. See below. |
conditions | object | yes | Five 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_profile | object | yes | {"A": …, "B": …}. The conditions already resolved against each contender's traits. See below. |
setting_sentence | string | yes | One sentence describing the whole setting, so the reply can restate it in conditions_read without reassembling five ids. |
extra | string | no | A 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_clipped | boolean | no | Whether the note above was clipped. |
guard_rules | array | no | {"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. |
prior | object | on rematch/challenge | A 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_text | string | on challenge | The 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.
kind | What it asserts |
|---|---|
measured | A direct measurement of real specimens or hardware, repeatable in principle. Rare, and the strongest label available. |
typical_range | A 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_spec | A published specification for a machine — what the maker or operator states. Not an independent measurement, and frequently optimistic. |
historical_estimate | A scholarly reconstruction. Contested by construction, and often a range across mutually incompatible sources. |
fictional_canon | Stated 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.
matchupis{"a": …, "b": …}, the two contender names.conditions_readrestates the setting in the reply's own words, so you can see whether the setting actually landed.evidence_basisis one ofdocumented·mixed·qualitative. It readsqualitativewhen at least one contender was sent within_table: false, which means most of the verdict is unsourced reasoning and should be read that way.sidesis exactly two entries withside"A"then"B". Each hascontender,brings[],liabilities[], andcondition_effectsof{"amplified": [], "nullified": [], "contested": []}— three arrays of plain strings, the third being what the setting pulls both ways.- Each
bringsentry is{"asset", "grounded_in", "why_it_matters"}; eachliabilitiesentry is{"issue", "grounded_in", "why_it_matters"}.grounded_inis a string or an array of strings — normalise it to a list before you check it. decisive_factoris{"claim", "mechanism", "grounded_in", "confidence"}, withconfidence∈high · medium · low.strongest_counteris{"claim", "grounded_in", "why_it_fails", "how_close"}, withhow_close∈decisive · substantial · thin. See below — it is required in every reply, including a refusal.likely_course[]is{"phase", "what_happens", "hinges_on"}— the engagement phase by phase, with what each phase turns on.verdictis{"favoured", "margin", "in_words", "how_often"}, withfavoured∈A · B · neitherandmargin∈decisive · clear · narrow · coin-flip.flips[]is{"change", "axis", "effect", "would_favour"}— what change to the setting would move the result, and which way. These map back onto the five condition axes, so a flip is directly runnable as arematch.not_measured[]andunknowns[]are plain string arrays: the parts of the verdict that rest on reasoning rather than measurement, and what nobody knows. A reply with both empty is suspicious, not clean.deltais{"changed", "what_moved": [], "what_held": [], "verdict_moved": bool, "challenge_answer", "reasoning"}, populated only onrematchandchallenge.verdict_moved: falseis a legitimate and useful answer — a rematch that always moves the verdict is a rematch that means nothing.
{
"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"
}
-
grounded_inuses the same closed vocabulary as everything else, and must differ fromdecisive_factor's basis. A counter-argument resting on the same evidence as the verdict is the verdict restated, not a counter to it. -
how_close∈decisive·substantial·thin— how nearly the counter succeeds.
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:
| Value | Means |
|---|---|
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.behaviour | One 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. |
behaviour | Documented behaviour without a side prefix — accepted, and the right choice when the claim rests on both sides' behaviour together. |
setting | The conditions or the resolved condition profile. |
reasoned | The 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:
-
Build the allowed set from the input. Walk every
stats[].valuestring of everyin_tableside 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. Recordside,statIdandkindalongside each. -
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 only360and will report a verdict that correctly quoted the lower end of a published range as having fabricated it. - 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.
-
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. - 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.
- 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.
- 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.
# Paste your token from /tokens.html and export it once.
export BS_TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"
# bs METHOD PATH [JSON-BODY] [EXTRA-HEADER]
bs() {
curl -sS -X "$1" "$BASE$2" \
-H "Authorization: Bearer $BS_TOKEN" \
-H "Content-Type: application/json" \
${4:+-H "$4"} \
${3:+-d "$3"}
}
# Envelope unwrap. Everything useful is under .data; a failure is
# {"error":{"code":"...","message":"..."}} and can arrive with a 200-shaped
# body, so read the code rather than trusting the status alone.
unwrap() {
python3 -c 'import sys,json; e=json.load(sys.stdin); sys.exit("API "+e["error"]["code"]+": "+e["error"]["message"]) if e.get("error") else print(json.dumps(e["data"], indent=2))'
}
import json, requests
TOKEN = "YOUR_TOKEN" # from /tokens.html
BASE = "https://api.skillsafe.ai/v1/app-api"
H = {"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json"}
class ApiError(RuntimeError):
def __init__(self, code, message):
super().__init__(f"{code}: {message}")
self.code = code
def call(method, path, body=None, extra=None, timeout=120):
r = requests.request(method, f"{BASE}{path}",
headers=dict(H, **(extra or {})),
json=body, timeout=timeout)
env = r.json()
if "error" in env:
raise ApiError(env["error"]["code"], env["error"]["message"])
return env["data"]
const TOKEN = "YOUR_TOKEN"; // from /tokens.html
const BASE = "https://api.skillsafe.ai/v1/app-api";
const H = { Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json" };
async function call(method, path, body, extra) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: { ...H, ...(extra || {}) },
body: body === undefined ? undefined : JSON.stringify(body)
});
const env = await res.json();
if (env.error) {
const err = new Error(`${env.error.code}: ${env.error.message}`);
err.code = env.error.code;
throw err;
}
return env.data;
}
package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
// The token comes from /tokens.html. Read it from the environment rather
// than pasting it into source: os.Getenv("SKILLSAFE_TOKEN").
func call(method, path string, body []byte, extra map[string]string) (json.RawMessage, error) {
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, _ := http.NewRequest(method, base+path, rdr)
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
req.Header.Set("Content-Type", "application/json")
for k, v := range extra {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env struct {
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if env.Error != nil {
return nil, errors.New(env.Error.Code + ": " + env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN"; // from /tokens.html
static final HttpClient HTTP = HttpClient.newHttpClient();
// Returns the raw envelope. Unwrap `data` with whichever JSON library you
// already use, and read `error.code` before trusting a 200.
static String call(String method, String path, String body,
String headerName, String headerValue) throws Exception {
var pub = (body == null)
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body);
var b = HttpRequest.newBuilder()
.uri(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, pub);
if (headerName != null) b = b.header(headerName, headerValue);
return HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString()).body();
}
require "net/http"
require "json"
TOKEN = "YOUR_TOKEN" # from /tokens.html
BASE = "https://api.skillsafe.ai/v1/app-api"
class ApiError < StandardError; end
def call(method, path, body = nil, extra = {})
uri = URI("#{BASE}#{path}")
klass = method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
extra.each { |k, v| req[k] = v }
req.body = JSON.generate(body) if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) { |h| h.request(req) }
env = JSON.parse(res.body)
raise ApiError, "#{env["error"]["code"]}: #{env["error"]["message"]}" if env["error"]
env["data"]
end
<?php
$token = "YOUR_TOKEN"; // from /tokens.html
$base = "https://api.skillsafe.ai/v1/app-api";
function bs_call($method, $path, $body = null, $extra = []) {
global $token, $base;
$ch = curl_init("$base$path");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => array_merge([
"Authorization: Bearer $token",
"Content-Type: application/json",
], $extra),
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$env = json_decode(curl_exec($ch), true);
if (isset($env["error"])) {
throw new RuntimeException($env["error"]["code"] . ": " . $env["error"]["message"]);
}
return $env["data"];
}
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var token = "YOUR_TOKEN"; // from /tokens.html
var baseUrl = "https://api.skillsafe.ai/v1/app-api";
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
async Task<JsonElement> Call(HttpMethod method, string path, string? body = null,
string? headerName = null, string? headerValue = null) {
var msg = new HttpRequestMessage(method, baseUrl + path);
if (body != null) msg.Content = new StringContent(body, Encoding.UTF8, "application/json");
if (headerName != null) msg.Headers.Add(headerName, headerValue);
var res = await http.SendAsync(msg);
var root = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (root.TryGetProperty("error", out var err))
throw new Exception(err.GetProperty("code").GetString() + ": " +
err.GetProperty("message").GetString());
return root.GetProperty("data");
}
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.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"battle-simulator"}'
# {"data":{"token":"...","guest_id":"..."}}
# Then: export BS_TOKEN="the token you just got"
g = requests.post(f"{BASE}/guest",
headers={"Content-Type": "application/json"},
json={"slug": "battle-simulator"}, timeout=30).json()["data"]
TOKEN = g["token"]
H["Authorization"] = f"Bearer {TOKEN}"
print("guest", g["guest_id"])
const g = (await (await fetch(`${BASE}/guest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "battle-simulator" })
})).json()).data;
H.Authorization = `Bearer ${g.token}`;
console.log("guest", g.guest_id);
// No Authorization header on this one.
body := []byte(`{"slug":"battle-simulator"}`)
req, _ := http.NewRequest("POST", base+"/guest", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var env struct {
Data struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
os.Setenv("SKILLSAFE_TOKEN", env.Data.Token)
var req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"battle-simulator\"}"))
.build();
// {"data":{"token":"...","guest_id":"..."}} — pull data.token out and use it
// as TOKEN above. No Authorization header is sent on this request.
System.out.println(HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body());
uri = URI("#{BASE}/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ slug: "battle-simulator" })
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
guest = JSON.parse(res.body)["data"]
puts guest["guest_id"]
# Use guest["token"] as TOKEN above.
<?php
$ch = curl_init("$base/guest");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(["slug" => "battle-simulator"]),
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
]);
$guest = json_decode(curl_exec($ch), true)["data"];
$token = $guest["token"]; // reuse as the bearer token above
echo $guest["guest_id"];
using var plain = new HttpClient(); // deliberately unauthenticated
var res = await plain.PostAsync("https://api.skillsafe.ai/v1/app-api/guest",
new StringContent("{\"slug\":\"battle-simulator\"}", Encoding.UTF8, "application/json"));
var guest = JsonDocument.Parse(await res.Content.ReadAsStringAsync())
.RootElement.GetProperty("data");
token = guest.GetProperty("token").GetString()!;
Console.WriteLine(guest.GetProperty("guest_id").GetString());
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.
bs GET /me | unwrap
# {"subject_type":"user","subject_id":"...","credits":1840}
# subject_type is "user" when signed in, and something else for a guest.
me = call("GET", "/me")
signed_in = me["subject_type"] == "user"
print(me["subject_type"], me["credits"], "signed in" if signed_in else "guest")
const me = await call("GET", "/me");
const signedIn = me.subject_type === "user";
console.log(me.subject_type, me.credits, signedIn ? "signed in" : "guest");
raw, err := call("GET", "/me", nil, nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits float64 `json:"credits"`
}
json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits, me.SubjectType == "user")
var body = call("GET", "/me", null, null, null);
// {"data":{"subject_type":"user","subject_id":"...","credits":1840}}
// Only those three fields exist; the signed-in test is subject_type "user".
System.out.println(body);
me = call("GET", "/me")
signed_in = me["subject_type"] == "user"
puts [me["subject_type"], me["credits"], signed_in ? "signed in" : "guest"].join(" ")
<?php
$me = bs_call("GET", "/me");
$signedIn = $me["subject_type"] === "user";
echo $me["subject_type"], " ", $me["credits"], " ", $signedIn ? "signed in" : "guest", "\n";
var me = await Call(HttpMethod.Get, "/me");
var subjectType = me.GetProperty("subject_type").GetString();
var credits = me.GetProperty("credits").GetDouble();
Console.WriteLine($"{subjectType} {credits} {(subjectType == "user" ? "signed in" : "guest")}");
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.
# Put the worked "adjudicate" body from above in input.json, then check its
# shape locally, because the endpoint will not check it for you.
python3 - input.json <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
assert isinstance(d, dict), "the body must be an object, not a wrapper or a list"
assert "input" not in d, "do not wrap the body in {\"input\": ...}"
assert d.get("shape") in ("adjudicate", "rematch", "challenge"), "bad shape"
assert isinstance(d.get("sides"), list) and len(d["sides"]) == 2, "sides must be exactly two"
PY
bs POST /estimate "$(cat input.json)" | unwrap
# {"hold_credits":...,"min_credits":...,"model":"gpt-5.6-terra","model_alias":"gpt-terra"}
SHAPES = ("adjudicate", "rematch", "challenge")
def must_be_valid(inp):
"""The endpoint validates nothing, so this is the only gate there is."""
if not isinstance(inp, dict):
raise TypeError(f"input must be a plain object, got {type(inp).__name__}")
if "input" in inp:
raise ValueError("do not wrap the body in {'input': ...}")
if inp.get("shape") not in SHAPES:
raise ValueError(f"shape must be one of {SHAPES}")
if not isinstance(inp.get("sides"), list) or len(inp["sides"]) != 2:
raise ValueError("sides must be exactly two fact blocks")
if inp["shape"] in ("rematch", "challenge") and not inp.get("prior"):
raise ValueError(f"{inp['shape']} requires prior")
if inp["shape"] == "challenge" and not (inp.get("challenge_text") or "").strip():
raise ValueError("challenge requires challenge_text")
return inp
payload = json.load(open("input.json")) # the worked body from above
est = call("POST", "/estimate", must_be_valid(payload))
print(est["hold_credits"], est["min_credits"], est["model"], est["model_alias"])
const SHAPES = ["adjudicate", "rematch", "challenge"];
// The endpoint validates nothing, so this is the only gate there is.
function mustBeValid(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new TypeError("input must be a plain object");
}
if ("input" in input) throw new Error("do not wrap the body in { input: ... }");
if (!SHAPES.includes(input.shape)) throw new Error("shape must be one of " + SHAPES);
if (!Array.isArray(input.sides) || input.sides.length !== 2) {
throw new Error("sides must be exactly two fact blocks");
}
if (input.shape !== "adjudicate" && !input.prior) throw new Error(input.shape + " requires prior");
if (input.shape === "challenge" && !(input.challenge_text || "").trim()) {
throw new Error("challenge requires challenge_text");
}
return input;
}
const est = await call("POST", "/estimate", mustBeValid(payload));
console.log(est.hold_credits, est.min_credits, est.model_alias);
// payload is the worked "adjudicate" body, marshalled from your own struct
// or read from disk. Validate it here: /estimate will not.
var check map[string]any
if err := json.Unmarshal(payload, &check); err != nil {
panic("body must be a JSON object")
}
if _, wrapped := check["input"]; wrapped {
panic(`do not wrap the body in {"input": ...}`)
}
if sides, ok := check["sides"].([]any); !ok || len(sides) != 2 {
panic("sides must be exactly two fact blocks")
}
raw, err := call("POST", "/estimate", payload, nil)
if err != nil {
panic(err)
}
var est struct {
HoldCredits float64 `json:"hold_credits"`
MinCredits float64 `json:"min_credits"`
ModelAlias string `json:"model_alias"`
}
json.Unmarshal(raw, &est)
fmt.Println(est.HoldCredits, est.MinCredits, est.ModelAlias)
// payload is the worked "adjudicate" body as a JSON string. Validate its
// shape with your JSON library before spending: /estimate will not.
if (payload.trim().charAt(0) != '{' || payload.contains("\"input\":")) {
throw new IllegalArgumentException("body must be the input object itself, unwrapped");
}
var body = call("POST", "/estimate", payload, null, null);
// {"data":{"hold_credits":...,"min_credits":...,"model":"gpt-5.6-terra",
// "model_alias":"gpt-terra"}}
// hold_credits is a reservation against the full output cap, not the price.
System.out.println(body);
SHAPES = %w[adjudicate rematch challenge].freeze
# The endpoint validates nothing, so this is the only gate there is.
def must_be_valid(input)
raise ArgumentError, "input must be a Hash" unless input.is_a?(Hash)
raise ArgumentError, "do not wrap the body in {input: ...}" if input.key?("input")
raise ArgumentError, "bad shape" unless SHAPES.include?(input["shape"])
raise ArgumentError, "sides must be exactly two" unless input["sides"].is_a?(Array) && input["sides"].size == 2
raise ArgumentError, "#{input["shape"]} requires prior" if input["shape"] != "adjudicate" && !input["prior"]
input
end
payload = JSON.parse(File.read("input.json"))
est = call("POST", "/estimate", must_be_valid(payload))
puts [est["hold_credits"], est["min_credits"], est["model_alias"]].join(" ")
<?php
// The endpoint validates nothing, so this is the only gate there is.
function must_be_valid(array $input): array {
if (array_is_list($input)) throw new InvalidArgumentException("body must be an object");
if (isset($input["input"])) throw new InvalidArgumentException("do not wrap the body");
if (!in_array($input["shape"] ?? "", ["adjudicate", "rematch", "challenge"], true)) {
throw new InvalidArgumentException("bad shape");
}
if (count($input["sides"] ?? []) !== 2) throw new InvalidArgumentException("sides must be two");
return $input;
}
$payload = json_decode(file_get_contents("input.json"), true);
$est = bs_call("POST", "/estimate", must_be_valid($payload));
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model_alias"], "\n";
// payload is the worked "adjudicate" body as a JSON string.
// Validate it here: /estimate will not.
var probe = JsonDocument.Parse(payload).RootElement;
if (probe.ValueKind != JsonValueKind.Object) throw new ArgumentException("body must be an object");
if (probe.TryGetProperty("input", out _)) throw new ArgumentException("do not wrap the body");
if (probe.GetProperty("sides").GetArrayLength() != 2) throw new ArgumentException("sides must be two");
var est = await Call(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"{est.GetProperty("hold_credits")} " +
$"{est.GetProperty("min_credits")} " +
$"{est.GetProperty("model_alias").GetString()}");
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.
# Submit. The body IS the input object - there is no {"input": ...} wrapper
# and there is no X-App-Slug header.
KEY="battle-simulator:adjudicate:8f3ka2-1x:a1"
JOB=$(bs POST /run "$(cat input.json)" "Idempotency-Key: $KEY" \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["job_id"])')
# Poll until terminal. Two seconds; do not tight-loop.
while true; do
S=$(bs GET "/jobs/$JOB")
echo "$S" | grep -q '"status":"succeeded"' && break
echo "$S" | grep -q '"status":"failed"' && { echo "$S"; exit 1; }
sleep 2
done
# The verdict is a JSON string at .data.output.output - parse it again.
echo "$S" | python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["output"]["output"])'
import time
key = "battle-simulator:adjudicate:8f3ka2-1x:a1"
job = call("POST", "/run", must_be_valid(payload), {"Idempotency-Key": key})
while job["status"] in ("queued", "running"):
time.sleep(2)
job = call("GET", f"/jobs/{job['job_id']}")
if job["status"] == "failed":
raise RuntimeError(job.get("error") or "the run did not complete")
verdict = json.loads(job["output"]["output"]) # a JSON string, parsed again
print(verdict["verdict"]["favoured"], verdict["verdict"]["margin"])
print(verdict["decisive_factor"]["claim"])
print(verdict["strongest_counter"]["how_close"])
const key = "battle-simulator:adjudicate:8f3ka2-1x:a1";
let job = await call("POST", "/run", mustBeValid(payload), { "Idempotency-Key": key });
while (job.status === "queued" || job.status === "running") {
await new Promise(r => setTimeout(r, 2000));
job = await call("GET", `/jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(job.error || "the run did not complete");
const verdict = JSON.parse(job.output.output); // a JSON string, parsed again
console.log(verdict.verdict.favoured, verdict.verdict.margin);
console.log(verdict.strongest_counter.how_close);
key := map[string]string{"Idempotency-Key": "battle-simulator:adjudicate:8f3ka2-1x:a1"}
raw, err := call("POST", "/run", payload, key)
if err != nil {
panic(err)
}
var job struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
json.Unmarshal(raw, &job)
for job.Status == "queued" || job.Status == "running" {
time.Sleep(2 * time.Second)
raw, err = call("GET", "/jobs/"+job.JobID, nil, nil)
if err != nil {
panic(err)
}
json.Unmarshal(raw, &job)
}
// job.Output.Output is a JSON string holding the verdict; unmarshal it again.
var verdict map[string]any
json.Unmarshal([]byte(job.Output.Output), &verdict)
var key = "battle-simulator:adjudicate:8f3ka2-1x:a1";
var submitted = call("POST", "/run", payload, "Idempotency-Key", key);
// Pull data.job_id out of `submitted`, then poll "/jobs/" + jobId every two
// seconds until data.status is "succeeded" or "failed".
String jobBody;
do {
Thread.sleep(2000);
jobBody = call("GET", "/jobs/" + jobId, null, null, null);
} while (jobBody.contains("\"status\":\"queued\"")
|| jobBody.contains("\"status\":\"running\""));
// The verdict is the JSON *string* at data.output.output - parse it again.
System.out.println(jobBody);
key = "battle-simulator:adjudicate:8f3ka2-1x:a1"
job = call("POST", "/run", must_be_valid(payload), { "Idempotency-Key" => key })
while %w[queued running].include?(job["status"])
sleep 2
job = call("GET", "/jobs/#{job["job_id"]}")
end
raise "the run did not complete" if job["status"] == "failed"
verdict = JSON.parse(job["output"]["output"]) # a JSON string, parsed again
puts verdict["verdict"]["favoured"], verdict["verdict"]["margin"]
puts verdict["strongest_counter"]["how_close"]
<?php
$key = "battle-simulator:adjudicate:8f3ka2-1x:a1";
$job = bs_call("POST", "/run", must_be_valid($payload), ["Idempotency-Key: $key"]);
while (in_array($job["status"], ["queued", "running"], true)) {
sleep(2);
$job = bs_call("GET", "/jobs/" . rawurlencode($job["job_id"]));
}
if ($job["status"] === "failed") {
throw new RuntimeException("the run did not complete");
}
$verdict = json_decode($job["output"]["output"], true); // parsed again
echo $verdict["verdict"]["favoured"], " ", $verdict["verdict"]["margin"], "\n";
echo $verdict["strongest_counter"]["how_close"], "\n";
var key = "battle-simulator:adjudicate:8f3ka2-1x:a1";
var job = await Call(HttpMethod.Post, "/run", payload, "Idempotency-Key", key);
var jobId = job.GetProperty("job_id").GetString();
var status = job.GetProperty("status").GetString();
while (status is "queued" or "running") {
await Task.Delay(2000);
job = await Call(HttpMethod.Get, $"/jobs/{jobId}");
status = job.GetProperty("status").GetString();
}
if (status == "failed") throw new Exception("the run did not complete");
// A JSON string holding the verdict - parse it again.
var verdict = JsonDocument.Parse(
job.GetProperty("output").GetProperty("output").GetString()!).RootElement;
Console.WriteLine(verdict.GetProperty("verdict").GetProperty("margin").GetString());
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.
curl -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \ -H "Authorization: Bearer $BS_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: battle-simulator:adjudicate:8f3ka2-1x:a1" \ -d @input.json # Frames arrive as `event: NAME` + `data: JSON`, separated by a blank line. # Concatenate the .text of every `delta`; the `done` frame carries # charged_credits and output.output.
out, done = "", None
headers = dict(H, **{"Idempotency-Key": "battle-simulator:adjudicate:8f3ka2-1x:a1"})
with requests.post(f"{BASE}/run-stream", headers=headers,
json=must_be_valid(payload), stream=True, timeout=600) as r:
event = "message"
for line in r.iter_lines(decode_unicode=True):
if line is None or line == "":
event = "message"
continue
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
evt = json.loads(line[5:].strip())
if event == "delta":
out += evt.get("text", "")
elif event in ("done", "pending"):
done = evt
elif event == "error":
raise ApiError(evt.get("code", "INTERNAL"), evt.get("message", ""))
print(done["charged_credits"])
verdict = json.loads(out)
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { ...H, "Idempotency-Key": "battle-simulator:adjudicate:8f3ka2-1x:a1" },
body: JSON.stringify(mustBeValid(payload))
});
// An idempotent replay answers with plain JSON instead of a stream.
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const env = await res.json();
return JSON.parse(env.data.output.output);
}
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", out = "", done = null;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buf += dec.decode(chunk.value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const frame = buf.slice(0, i);
buf = buf.slice(i + 2);
let name = "message", data = "";
for (const l of frame.split("\n")) {
if (l.startsWith("event:")) name = l.slice(6).trim();
else if (l.startsWith("data:")) data += l.slice(5).trim();
}
if (!data) continue;
const evt = JSON.parse(data);
if (name === "delta") out += evt.text || "";
else if (name === "done" || name === "pending") done = evt;
else if (name === "error") throw new Error(`${evt.code}: ${evt.message}`);
}
}
console.log(done.charged_credits);
const verdict = JSON.parse(out);
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("SKILLSAFE_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "battle-simulator:adjudicate:8f3ka2-1x:a1")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var out strings.Builder
name := "message"
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case line == "":
name = "message"
case strings.HasPrefix(line, "event:"):
name = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:") && name == "delta":
var evt struct {
Text string `json:"text"`
}
json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &evt)
out.WriteString(evt.Text)
}
}
var verdict map[string]any
json.Unmarshal([]byte(out.String()), &verdict)
var req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "battle-simulator:adjudicate:8f3ka2-1x:a1")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
var out = new StringBuilder();
var name = new String[]{ "message" };
HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
if (line.isEmpty()) { name[0] = "message"; }
else if (line.startsWith("event:")) { name[0] = line.substring(6).trim(); }
else if (line.startsWith("data:") && name[0].equals("delta")) {
// Parse the frame with your JSON library and append its `text`.
out.append(textOf(line.substring(5).trim()));
}
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "battle-simulator:adjudicate:8f3ka2-1x:a1"
req.body = JSON.generate(must_be_valid(payload))
out = +""
name = "message"
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 600) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.empty? then name = "message"
elsif line.start_with?("event:") then name = line[6..].strip
elsif line.start_with?("data:")
evt = JSON.parse(line[5..].strip)
out << evt["text"].to_s if name == "delta"
end
end
end
end
end
verdict = JSON.parse(out)
<?php
$out = "";
$name = "message";
$ch = curl_init("$base/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(must_be_valid($payload)),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
"Idempotency-Key: battle-simulator:adjudicate:8f3ka2-1x:a1",
],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$out, &$name) {
foreach (explode("\n", $chunk) as $line) {
$line = rtrim($line, "\r");
if ($line === "") {
$name = "message";
} elseif (str_starts_with($line, "event:")) {
$name = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:") && $name === "delta") {
$evt = json_decode(trim(substr($line, 5)), true);
$out .= $evt["text"] ?? "";
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
$verdict = json_decode($out, true);
var msg = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/run-stream") {
Content = new StringContent(payload, Encoding.UTF8, "application/json")
};
msg.Headers.Add("Idempotency-Key", "battle-simulator:adjudicate:8f3ka2-1x:a1");
var res = await http.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await res.Content.ReadAsStreamAsync());
var sb = new StringBuilder();
var name = "message";
while (await sr.ReadLineAsync() is string line) {
if (line.Length == 0) { name = "message"; }
else if (line.StartsWith("event:")) { name = line[6..].Trim(); }
else if (line.StartsWith("data:") && name == "delta") {
var evt = JsonDocument.Parse(line[5..].Trim()).RootElement;
if (evt.TryGetProperty("text", out var t)) sb.Append(t.GetString());
}
}
var verdict = JsonDocument.Parse(sb.ToString()).RootElement;
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:
- Strip a leading
```jsonor```and a trailing```. - 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. - 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. - 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.
REQUIRED='title matchup conditions_read evidence_basis sides decisive_factor
strongest_counter likely_course verdict flips not_measured unknowns'
bs GET "/jobs/$JOB" \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["output"]["output"])' \
| sed -e 's/^```json//' -e 's/^```//' -e 's/```$//' \
| python3 -c "
import sys, json
v = json.loads(sys.stdin.read())
missing = [k for k in '''$REQUIRED'''.split() if k not in v]
print('missing:', missing or 'none')
print(v['verdict']['favoured'], v['verdict']['margin'])
"
import re
REQUIRED = ("title", "matchup", "conditions_read", "evidence_basis", "sides",
"decisive_factor", "strongest_counter", "likely_course", "verdict",
"flips", "not_measured", "unknowns")
def first_object(s):
"""The first {...} in the reply, fence and preamble discarded."""
t = re.sub(r"^```(?:json)?\s*", "", s.strip())
t = re.sub(r"```\s*$", "", t).strip()
start = t.find("{")
if start < 0:
raise ValueError("no JSON object in the reply")
depth, in_str, esc = 0, False, False
for i in range(start, len(t)):
c = t[i]
if esc: esc = False; continue
if c == "\\": esc = True; continue
if c == '"': in_str = not in_str; continue
if in_str: continue
if c == "{": depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return t[start:i + 1], True
return t[start:], False # truncated: repair before parsing
def parse_verdict(raw):
text, complete = first_object(raw)
v = json.loads(text) # if not complete, repair first
missing = [k for k in REQUIRED if k not in v]
if missing:
raise ValueError("reply is missing " + ", ".join(missing))
if v["evidence_basis"] not in ("documented", "mixed", "qualitative"):
raise ValueError("bad evidence_basis")
if v["verdict"]["margin"] not in ("decisive", "clear", "narrow", "coin-flip"):
raise ValueError("bad margin")
if len(v["sides"]) != 2 or [s["side"] for s in v["sides"]] != ["A", "B"]:
raise ValueError("sides must be A then B")
# Calibration: a counter that nearly lands forbids a decisive verdict.
if v["strongest_counter"]["how_close"] in ("decisive", "substantial"):
if v["decisive_factor"]["confidence"] == "high" or v["verdict"]["margin"] == "decisive":
raise ValueError("reply concedes a strong counter and still claims certainty")
return v
const REQUIRED = ["title", "matchup", "conditions_read", "evidence_basis", "sides",
"decisive_factor", "strongest_counter", "likely_course", "verdict",
"flips", "not_measured", "unknowns"];
function firstObject(s) {
let t = String(s || "").trim()
.replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "").trim();
const start = t.indexOf("{");
if (start === -1) throw new Error("no JSON object in the reply");
let depth = 0, inStr = false, esc = false;
for (let i = start; i < t.length; i++) {
const c = t[i];
if (esc) { esc = false; continue; }
if (c === "\\") { esc = true; continue; }
if (c === '"') { inStr = !inStr; continue; }
if (inStr) continue;
if (c === "{") depth++;
else if (c === "}" && --depth === 0) return { text: t.slice(start, i + 1), complete: true };
}
return { text: t.slice(start), complete: false }; // truncated: repair first
}
function parseVerdict(raw) {
const { text } = firstObject(raw);
const v = JSON.parse(text);
const missing = REQUIRED.filter(k => !(k in v));
if (missing.length) throw new Error("reply is missing " + missing.join(", "));
if (!["documented", "mixed", "qualitative"].includes(v.evidence_basis)) {
throw new Error("bad evidence_basis");
}
if (v.sides.length !== 2 || v.sides[0].side !== "A" || v.sides[1].side !== "B") {
throw new Error("sides must be A then B");
}
return v;
}
var required = []string{"title", "matchup", "conditions_read", "evidence_basis",
"sides", "decisive_factor", "strongest_counter", "likely_course", "verdict",
"flips", "not_measured", "unknowns"}
// firstObject returns the first {...} in the reply, with any code fence and
// preamble discarded. complete is false when the stream was cut mid-object.
func firstObject(s string) (text string, complete bool) {
t := strings.TrimSpace(s)
t = strings.TrimPrefix(strings.TrimPrefix(t, "```json"), "```")
t = strings.TrimSuffix(strings.TrimSpace(t), "```")
start := strings.Index(t, "{")
if start < 0 {
return "", false
}
depth, inStr, esc := 0, false, false
for i := start; i < len(t); i++ {
c := t[i]
switch {
case esc:
esc = false
case c == '\\':
esc = true
case c == '"':
inStr = !inStr
case inStr:
case c == '{':
depth++
case c == '}':
depth--
if depth == 0 {
return t[start : i+1], true
}
}
}
return t[start:], false
}
text, _ := firstObject(job.Output.Output)
var v map[string]any
json.Unmarshal([]byte(text), &v)
for _, k := range required {
if _, ok := v[k]; !ok {
panic("reply is missing " + k)
}
}
static final String[] REQUIRED = {
"title", "matchup", "conditions_read", "evidence_basis", "sides",
"decisive_factor", "strongest_counter", "likely_course", "verdict",
"flips", "not_measured", "unknowns"
};
// The first {...} in the reply, fence and preamble discarded. Returns the
// fragment as-is when the stream was cut, so the caller can repair it.
static String firstObject(String s) {
var t = s.strip().replaceFirst("^```(?:json)?\\s*", "").replaceFirst("```\\s*$", "").strip();
int start = t.indexOf('{');
if (start < 0) throw new IllegalStateException("no JSON object in the reply");
int depth = 0; boolean inStr = false, esc = false;
for (int i = start; i < t.length(); i++) {
char c = t.charAt(i);
if (esc) { esc = false; continue; }
if (c == '\\') { esc = true; continue; }
if (c == '"') { inStr = !inStr; continue; }
if (inStr) continue;
if (c == '{') depth++;
else if (c == '}' && --depth == 0) return t.substring(start, i + 1);
}
return t.substring(start);
}
// Parse firstObject(...) with your JSON library, then assert every key in
// REQUIRED is present before rendering anything.
REQUIRED = %w[title matchup conditions_read evidence_basis sides
decisive_factor strongest_counter likely_course verdict
flips not_measured unknowns].freeze
def first_object(s)
t = s.to_s.strip.sub(/\A```(?:json)?\s*/i, "").sub(/```\s*\z/, "").strip
start = t.index("{")
raise "no JSON object in the reply" unless start
depth = 0; in_str = false; esc = false
(start...t.length).each do |i|
c = t[i]
if esc then esc = false
elsif c == "\\" then esc = true
elsif c == '"' then in_str = !in_str
elsif in_str then next
elsif c == "{" then depth += 1
elsif c == "}"
depth -= 1
return [t[start..i], true] if depth.zero?
end
end
[t[start..], false] # truncated: repair before parsing
end
text, = first_object(job["output"]["output"])
v = JSON.parse(text)
missing = REQUIRED.reject { |k| v.key?(k) }
raise "reply is missing #{missing.join(", ")}" unless missing.empty?
<?php
const REQUIRED = ["title", "matchup", "conditions_read", "evidence_basis", "sides",
"decisive_factor", "strongest_counter", "likely_course", "verdict",
"flips", "not_measured", "unknowns"];
function first_object(string $s): array {
$t = preg_replace('/^```(?:json)?\s*/i', "", trim($s));
$t = trim(preg_replace('/```\s*$/', "", $t));
$start = strpos($t, "{");
if ($start === false) throw new RuntimeException("no JSON object in the reply");
$depth = 0; $inStr = false; $esc = false;
for ($i = $start; $i < strlen($t); $i++) {
$c = $t[$i];
if ($esc) { $esc = false; continue; }
if ($c === "\\") { $esc = true; continue; }
if ($c === '"') { $inStr = !$inStr; continue; }
if ($inStr) continue;
if ($c === "{") $depth++;
elseif ($c === "}" && --$depth === 0) return [substr($t, $start, $i - $start + 1), true];
}
return [substr($t, $start), false]; // truncated: repair before parsing
}
[$text, ] = first_object($job["output"]["output"]);
$v = json_decode($text, true);
$missing = array_values(array_filter(REQUIRED, fn($k) => !array_key_exists($k, $v)));
if ($missing) throw new RuntimeException("reply is missing " . implode(", ", $missing));
static readonly string[] Required = {
"title", "matchup", "conditions_read", "evidence_basis", "sides",
"decisive_factor", "strongest_counter", "likely_course", "verdict",
"flips", "not_measured", "unknowns"
};
// The first {...} in the reply, fence and preamble discarded. complete is
// false when the stream was cut mid-object, so the caller can repair it.
static (string Text, bool Complete) FirstObject(string s) {
var t = s.Trim();
if (t.StartsWith("```json")) t = t[7..];
else if (t.StartsWith("```")) t = t[3..];
if (t.EndsWith("```")) t = t[..^3];
t = t.Trim();
var start = t.IndexOf('{');
if (start < 0) throw new InvalidOperationException("no JSON object in the reply");
int depth = 0; bool inStr = false, esc = false;
for (var i = start; i < t.Length; i++) {
var c = t[i];
if (esc) { esc = false; continue; }
if (c == '\\') { esc = true; continue; }
if (c == '"') { inStr = !inStr; continue; }
if (inStr) continue;
if (c == '{') depth++;
else if (c == '}' && --depth == 0) return (t[start..(i + 1)], true);
}
return (t[start..], false);
}
var (text, _) = FirstObject(jobOutput);
var v = JsonDocument.Parse(text).RootElement;
foreach (var k in Required)
if (!v.TryGetProperty(k, out _))
throw new Exception($"reply is missing {k}");