← Turn & Hex / API
Tokens

Drive Turn & Hex from your own code

Both of the board's asks are available over HTTP. A natural use is a virtual-tabletop bot or a club's session tool that already keeps initiative and a party sheet: it posts the state between turns and shows the GM who to spotlight next, or re-themes a prepped encounter before the session starts.

One thing to be clear about first: the model never sees the map. The caller sends the party, the named NPCs, the encounter (threat ids from the bundled table, grouped with counts), the budget, the tone and - for beats - the turn order, the spotlight tally and the last few scene markers. The model returns facilitation; the caller checks every name, every threat id and the budget against its own state before showing anything. The board's code ships as plain scripts you can load in node — see the end of the examples.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }

The token is minted for this app (the guest call takes {"slug":"turn-and-hex"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object: never wrap it as {"input": {…}} — that returns 200, warns unknown field 'input', and hides every field from the model.

Errors:

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

The task field comes first

One system prompt serves two tasks. task picks the contract; a missing or unknown task makes the model pick the closest one and name it in the reply's task.

taskWhenExtra fields
beatsMid-scene: who to spotlight next and why (a hero with the lowest tally unless the input says why not), 1-3 complications that fit the scene markers, a pacing call (cut, linger, escalate) with an energy level, questions to ask out loud, and one line of delivery advice. From the skill game-facilitator.turn_state, spotlight, scenes.
rethemeBefore play: a tonal diagnosis of the encounter (fit, flat, whiplash, purple, monotone, genre mismatch), an anchor sentence, up to six threat swaps (id to id, from the catalog only) that keep the encounter inside the GM's difficulty band, an energy call and a short read-aloud opener. From the skill table-tone.catalog.

Every run body is a JSON object of string fields (the declared input schema; task, party and encounter are required, and /estimate warns when one is missing).

FieldWhat it holds
task"beats" or "retheme".
partyJSON string: [{"name","concept","hp":"8/22","stress":"0/6","conditions":["prone (1)"],"down":false}].
npcsJSON string: [{"name","side":"ally|foe","note"}] - the only other names the reply may use.
encounterJSON string: [{"id","name","tier":1,"role","themes":[...],"count":2,"standing":2}] - ids from the bundled threat table (threats.js, 104 original threats).
budgetJSON string: {"party_tier","party_size","difficulty":"easy|standard|hard|deadly","points","target":"lo-hi","band"}. Points per creature = role cost x tier factor (see below).
toneJSON string: {"anchor","genre","humor":"integrated|bracketed|rare|none","consequence":"light|medium|heavy|brutal"}.
turn_statebeats: {"combat":true,"round":2,"acting":"Brisk","order":[{"name","init","side","hp","state","conditions","acting"}]}; past 16 creatures the acting one and the next 15 are sent.
spotlightbeats: {"heroes":[{"name","acted","spoke","scene_score","session_total"}],"lowest":["Talia"]}.
scenesbeats: the last 5 scene markers, [{"title","kind","minute","note"}]; the last is the scene being played.
catalogretheme: candidate threats, one per line id|name|tier|role|themes - threats sharing a role with the encounter within one tier, at most 70.
gm_noteOptional free text; over 600 characters the middle is cut with a marker.
clippedOptional: what was shortened or left out.

The reply text is one JSON object:

// task "beats"
{ "task": "beats", "read": "...",
  "spotlight": { "name": "Talia", "lowest": true, "why": "...", "move": "...", "why_not_lowest": "" },
  "complications": [{ "title": "...", "detail": "...", "kind": "twist|pressure|cost|reveal|npc|clock",
                      "characters": ["Old Fenwick"], "threats": ["ditch-captain"], "ties_to": "Ambush on the bridge" }],
  "pacing": { "call": "cut|linger|escalate", "energy": 1-5, "scene_end": "yes-but|no-and|no|yes|open", "why": "..." },
  "prompts": [{ "to": "Talia", "line": "..." }],
  "tone_note": "..." }

// task "retheme"
{ "task": "retheme",
  "diagnosis": { "state": "fit|flat|whiplash|purple|monotone|genre-mismatch", "summary": "..." },
  "anchor": "...",
  "swaps": [{ "from": "watch-sergeant", "to": "plague-abbot", "reason": "..." }],
  "budget_intent": "lower|hold|raise",
  "energy": { "scene_type": "exploration|social|combat|investigation|climax|denouement", "level": "low|medium|high|maximum", "delivery": "..." },
  "opener": "..." }

Check before you show it: a spotlight name outside party gets no halo; a spotlight that skips the lowest tally without why_not_lowest is flagged; a name in characters or prompts that is not a hero, an NPC or a threat is flagged; a swap whose from is not in the encounter, whose to is not a threat id, or that repeats a from, is dropped; and the budget after the swaps is recomputed and marked when it leaves the target band.

Worked examples

These are the bundled examples on the page: the exact run body the page builds and a reply produced against the shipped prompt (the page replays the same replies for free).

beats: round 2 of an ambush on a toll bridge, one hero has barely acted

Input:

{
 "task": "beats",
 "party": "(a JSON string - decoded below)",
 "npcs": "(a JSON string - decoded below)",
 "encounter": "(a JSON string - decoded below)",
 "budget": "(a JSON string - decoded below)",
 "tone": "(a JSON string - decoded below)",
 "turn_state": "(a JSON string - decoded below)",
 "spotlight": "(a JSON string - decoded below)",
 "scenes": "(a JSON string - decoded below)",
 "gm_note": "Talia's player has been quiet all evening. The cart holds the shrine bell the party promised to deliver."
}

The JSON-string fields, decoded:

{
 "party": [
  {
   "name": "Wren",
   "concept": "half-elf scout, bow",
   "hp": "8/22",
   "stress": "0/6",
   "conditions": [],
   "down": false
  },
  {
   "name": "Odo",
   "concept": "dwarf cleric of the hearth",
   "hp": "26/26",
   "stress": "0/6",
   "conditions": [],
   "down": false
  },
  {
   "name": "Brisk",
   "concept": "human duelist, loud",
   "hp": "4/20",
   "stress": "0/6",
   "conditions": [
    "prone (1)"
   ],
   "down": false
  },
  {
   "name": "Talia",
   "concept": "hedge-witch, shy apprentice",
   "hp": "16/16",
   "stress": "0/6",
   "conditions": [],
   "down": false
  }
 ],
 "npcs": [
  {
   "name": "Old Fenwick",
   "side": "ally",
   "note": "toll-keeper, owes the brigands money"
  }
 ],
 "encounter": [
  {
   "id": "ditch-captain",
   "name": "Ditch Captain",
   "tier": 1,
   "role": "leader",
   "themes": [
    "bandit",
    "soldier"
   ],
   "count": 1,
   "standing": 1
  },
  {
   "id": "toll-bridge-brigand",
   "name": "Toll Bridge Brigand",
   "tier": 1,
   "role": "skirmisher",
   "themes": [
    "bandit",
    "wild"
   ],
   "count": 2,
   "standing": 2
  },
  {
   "id": "alley-slinger",
   "name": "Alley Slinger",
   "tier": 1,
   "role": "artillery",
   "themes": [
    "urban",
    "bandit"
   ],
   "count": 1,
   "standing": 1
  }
 ],
 "budget": {
  "party_tier": 1,
  "party_size": 4,
  "difficulty": "standard",
  "points": 9,
  "target": "6-10",
  "band": "standard"
 },
 "tone": {
  "anchor": "Scrappy heroes, real danger, a little mud-splattered humour",
  "genre": "heroic fantasy",
  "humor": "bracketed",
  "consequence": "medium"
 },
 "turn_state": {
  "combat": true,
  "round": 2,
  "acting": "Brisk",
  "order": [
   {
    "name": "Wren",
    "init": 17,
    "side": "party",
    "hp": "8/22",
    "state": "active",
    "conditions": [],
    "acting": false
   },
   {
    "name": "Ditch Captain",
    "init": 15,
    "side": "foe",
    "hp": "26/26",
    "state": "active",
    "conditions": [],
    "acting": false
   },
   {
    "name": "Brisk",
    "init": 14,
    "side": "party",
    "hp": "4/20",
    "state": "active",
    "conditions": [
     "prone"
    ],
    "acting": true
   },
   {
    "name": "Toll Bridge Brigand 1",
    "init": 13,
    "side": "foe",
    "hp": "11/20",
    "state": "active",
    "conditions": [],
    "acting": false
   },
   {
    "name": "Toll Bridge Brigand 2",
    "init": 13,
    "side": "foe",
    "hp": "20/20",
    "state": "active",
    "conditions": [],
    "acting": false
   },
   {
    "name": "Talia",
    "init": 12,
    "side": "party",
    "hp": "16/16",
    "state": "active",
    "conditions": [],
    "acting": false
   },
   {
    "name": "Alley Slinger",
    "init": 11,
    "side": "foe",
    "hp": "12/12",
    "state": "active",
    "conditions": [
     "marked"
    ],
    "acting": false
   },
   {
    "name": "Odo",
    "init": 9,
    "side": "party",
    "hp": "26/26",
    "state": "active",
    "conditions": [],
    "acting": false
   },
   {
    "name": "Old Fenwick",
    "init": 6,
    "side": "ally",
    "hp": "8/8",
    "state": "active",
    "conditions": [],
    "acting": false
   }
  ]
 },
 "spotlight": {
  "heroes": [
   {
    "name": "Wren",
    "acted": 2,
    "spoke": 2,
    "scene_score": 4,
    "session_total": 7
   },
   {
    "name": "Odo",
    "acted": 1,
    "spoke": 1,
    "scene_score": 2,
    "session_total": 6
   },
   {
    "name": "Brisk",
    "acted": 2,
    "spoke": 3,
    "scene_score": 5,
    "session_total": 10
   },
   {
    "name": "Talia",
    "acted": 0,
    "spoke": 0,
    "scene_score": 0,
    "session_total": 1
   }
  ],
  "lowest": [
   "Talia"
  ]
 },
 "scenes": [
  {
   "title": "Arrival at the toll house",
   "kind": "social",
   "minute": 0,
   "note": "Fenwick is nervous, keeps glancing at the far bank."
  },
  {
   "title": "Ambush on the bridge",
   "kind": "combat",
   "minute": 31,
   "note": "Brigands drop from the trees; the captain demands the cart."
  }
 ]
}

Reply:

{
 "task": "beats",
 "read": "Round two on the bridge: the brigands want the cart with the shrine bell, Wren and Brisk are badly hurt, and the fight is loud but one-sided in who gets to act.",
 "spotlight": {
  "name": "Talia",
  "lowest": true,
  "why": "Talia has not acted or spoken all scene; a shy hedge-witch at full health is the one hero nobody is watching, which makes her the perfect person to change the fight.",
  "move": "The cart horse rears and the shrine bell starts to slide off the back toward the rail - Talia is the only one close enough. What does your craft do when the bell is about to go into the river?",
  "why_not_lowest": ""
 },
 "complications": [
  {
   "title": "Fenwick switches sides, maybe",
   "detail": "Old Fenwick shouts that he can settle his debt if they just hand over the cart, then hesitates with his hand on the toll-gate lever - he could drop it on the brigands or on the heroes.",
   "kind": "npc",
   "characters": [
    "Old Fenwick",
    "Ditch Captain"
   ],
   "threats": [
    "ditch-captain"
   ],
   "ties_to": "Arrival at the toll house"
  },
  {
   "title": "Brigands go for the cart",
   "detail": "Instead of finishing Brisk, both Toll Bridge Brigands cut the cart's traces and start hauling it toward the far bank - the heroes can save the bell or chase the fighters, not both cleanly.",
   "kind": "pressure",
   "characters": [
    "Toll Bridge Brigand"
   ],
   "threats": [
    "toll-bridge-brigand"
   ],
   "ties_to": "Ambush on the bridge"
  },
  {
   "title": "The marked slinger repositions",
   "detail": "The Alley Slinger, marked and nervous, scrambles up onto the bridge railing for a clear shot - yes, it has a line on everyone, but one shove would send it into the water.",
   "kind": "twist",
   "characters": [
    "Alley Slinger"
   ],
   "threats": [
    "alley-slinger"
   ],
   "ties_to": "Ambush on the bridge"
  }
 ],
 "pacing": {
  "call": "escalate",
  "energy": 4,
  "scene_end": "yes-but",
  "why": "The conflict is live but the same two heroes keep trading blows, so raise the stakes around the cart to pull the whole table in."
 },
 "prompts": [
  {
   "to": "Talia",
   "line": "Talia, the bell hums when your hand gets close to it - what do you feel through it?"
  },
  {
   "to": "Odo",
   "line": "Odo, Brisk is down in the mud at your feet and the cart is moving - who do you go to first?"
  }
 ],
 "tone_note": "Keep hits quick and muddy, let one bit of slapstick land on the brigands, then make the bell sliding toward the water feel genuinely tense."
}

beats: a haggling scene gone round three times, the quietest player away from the table

Input (the gm_note is why the reply may skip the lowest tally - and must say so):

{
 "task": "beats",
 "party": "(a JSON string - decoded below)",
 "npcs": "(a JSON string - decoded below)",
 "encounter": "(a JSON string - decoded below)",
 "budget": "(a JSON string - decoded below)",
 "tone": "(a JSON string - decoded below)",
 "turn_state": "(a JSON string - decoded below)",
 "spotlight": "(a JSON string - decoded below)",
 "scenes": "(a JSON string - decoded below)",
 "gm_note": "Mara's player stepped out for a phone call about ten minutes ago and will be back soon. Jory's player is having a great time but the negotiation has gone round three times. The captain's riot squad is two streets away."
}

The JSON-string fields, decoded:

{
 "party": [
  {
   "name": "Mara",
   "concept": "artificer, owes the guild",
   "hp": "70/70",
   "stress": "0/6",
   "conditions": [],
   "down": false
  },
  {
   "name": "Jory",
   "concept": "silver-tongued bard",
   "hp": "64/64",
   "stress": "0/6",
   "conditions": [],
   "down": false
  },
  {
   "name": "Kestrel",
   "concept": "ranger, hates cities",
   "hp": "78/78",
   "stress": "0/6",
   "conditions": [],
   "down": false
  },
  {
   "name": "Pell",
   "concept": "monk of the closed hand",
   "hp": "72/72",
   "stress": "0/6",
   "conditions": [],
   "down": false
  },
  {
   "name": "Quill",
   "concept": "warlock of a clock-saint",
   "hp": "60/60",
   "stress": "0/6",
   "conditions": [],
   "down": false
  }
 ],
 "npcs": [
  {
   "name": "Gearwright Ossa",
   "side": "ally",
   "note": "market master, wants the riot over before the guild notices"
  },
  {
   "name": "Teo",
   "side": "ally",
   "note": "street kid who saw the theft"
  }
 ],
 "encounter": [
  {
   "id": "free-company-captain",
   "name": "Free Company Captain",
   "tier": 3,
   "role": "leader",
   "themes": [
    "soldier",
    "bandit"
   ],
   "count": 1,
   "standing": 1
  },
  {
   "id": "clockwork-drone",
   "name": "Clockwork Drone",
   "tier": 3,
   "role": "minion",
   "themes": [
    "construct",
    "arcane",
    "swarm"
   ],
   "count": 4,
   "standing": 4
  },
  {
   "id": "shadowstep-assassin",
   "name": "Shadowstep Assassin",
   "tier": 3,
   "role": "skirmisher",
   "themes": [
    "shadow",
    "bandit"
   ],
   "count": 1,
   "standing": 1
  }
 ],
 "budget": {
  "party_tier": 3,
  "party_size": 5,
  "difficulty": "easy",
  "points": 7,
  "target": "4-7.5",
  "band": "easy"
 },
 "tone": {
  "anchor": "Competent rogues in a city that runs on debts and gears",
  "genre": "swashbuckling",
  "humor": "integrated",
  "consequence": "medium"
 },
 "turn_state": {
  "combat": false,
  "round": 0,
  "acting": "",
  "order": []
 },
 "spotlight": {
  "heroes": [
   {
    "name": "Mara",
    "acted": 0,
    "spoke": 0,
    "scene_score": 0,
    "session_total": 4
   },
   {
    "name": "Jory",
    "acted": 2,
    "spoke": 6,
    "scene_score": 8,
    "session_total": 17
   },
   {
    "name": "Kestrel",
    "acted": 0,
    "spoke": 1,
    "scene_score": 1,
    "session_total": 7
   },
   {
    "name": "Pell",
    "acted": 1,
    "spoke": 1,
    "scene_score": 2,
    "session_total": 7
   },
   {
    "name": "Quill",
    "acted": 1,
    "spoke": 2,
    "scene_score": 3,
    "session_total": 6
   }
  ],
  "lowest": [
   "Mara"
  ]
 },
 "scenes": [
  {
   "title": "The stolen mainspring",
   "kind": "social",
   "minute": 0,
   "note": "Ossa hires them quietly."
  },
  {
   "title": "Chase over the rooftops",
   "kind": "combat",
   "minute": 38,
   "note": "Pell and Kestrel lost the thief at the guild hall."
  },
  {
   "title": "Haggling with the Gearwright",
   "kind": "social",
   "minute": 71,
   "note": "Jory is negotiating the reward up, again."
  }
 ]
}

Reply:

{
 "task": "beats",
 "read": "The haggle with Gearwright Ossa has gone round three times; the reward is settled in spirit, the riot squad is two streets away, and the energy has gone flat.",
 "spotlight": {
  "name": "Kestrel",
  "lowest": false,
  "why": "Kestrel has the next-lowest tally and hates cities - the ranger who lost the thief at the guild hall is the right person to break the stalled haggle with something real.",
  "move": "Kestrel, from the window you see the thief's tracks you lost at the guild hall - and Teo pointing at them. Do you go now, before the deal is done?",
  "why_not_lowest": "Mara is lowest, but her player stepped out for a phone call; save the guild-debt hook for her return."
 },
 "complications": [
  {
   "title": "The riot squad arrives early",
   "detail": "Boots and whirring gears in the street: the Free Company Captain's squad with Clockwork Drones is one street away, and Ossa needs this over before the guild notices.",
   "kind": "clock",
   "characters": [
    "Free Company Captain",
    "Gearwright Ossa"
   ],
   "threats": [
    "free-company-captain",
    "clockwork-drone"
   ],
   "ties_to": "Haggling with the Gearwright"
  },
  {
   "title": "Teo names the thief",
   "detail": "Teo slips in and whispers that the thief wore a hood and moved through shadows like smoke - a Shadowstep Assassin, still in the guild hall.",
   "kind": "reveal",
   "characters": [
    "Teo",
    "Shadowstep Assassin"
   ],
   "threats": [
    "shadowstep-assassin"
   ],
   "ties_to": "Chase over the rooftops"
  }
 ],
 "pacing": {
  "call": "cut",
  "energy": 4,
  "scene_end": "yes-but",
  "why": "The negotiation is going in circles, so give Jory the higher reward with a catch and cut straight to the street."
 },
 "prompts": [
  {
   "to": "Kestrel",
   "line": "Kestrel, the tracks are leading back into the city you hate - how do you follow them?"
  },
  {
   "to": "Jory",
   "line": "Jory, Ossa agrees to your price if you keep the riot squad busy - do you take it?"
  },
  {
   "to": "Quill",
   "line": "Quill, your clock-saint ticks louder as the drones approach - what is it telling you?"
  }
 ],
 "tone_note": "Snap the tempo: one witty line to close the deal, then go fast and physical as the gears start clattering in the street."
}

retheme: a town-watch patrol prepped for a cosmic-horror night, under its hard band

Input:

{
 "task": "retheme",
 "party": "(a JSON string - decoded below)",
 "npcs": "(a JSON string - decoded below)",
 "encounter": "(a JSON string - decoded below)",
 "budget": "(a JSON string - decoded below)",
 "tone": "(a JSON string - decoded below)",
 "catalog": "(id|name|tier|role|themes lines - shown below)",
 "gm_note": "I statted a town-watch patrol out of habit, but this is meant to feel like the sea itself is hunting them. The table wants it to be hard.",
 "clipped": "catalog: sent 23 of 104 threats (roles artillery, leader, minion; within one tier)"
}

The JSON-string fields, decoded:

{
 "party": [
  {
   "name": "Sister Ilse",
   "concept": "lapsed tide-priest",
   "hp": "30/30",
   "stress": "0/8",
   "conditions": [],
   "down": false
  },
  {
   "name": "Corvo",
   "concept": "smuggler with a debt",
   "hp": "28/28",
   "stress": "0/8",
   "conditions": [],
   "down": false
  },
  {
   "name": "Neve",
   "concept": "cartographer of drowned places",
   "hp": "24/24",
   "stress": "0/8",
   "conditions": [],
   "down": false
  }
 ],
 "npcs": [
  {
   "name": "The Bellringer",
   "side": "foe",
   "note": "drowned for forty years, still rings at low tide"
  }
 ],
 "encounter": [
  {
   "id": "watch-sergeant",
   "name": "Watch Sergeant",
   "tier": 2,
   "role": "leader",
   "themes": [
    "soldier",
    "urban"
   ],
   "count": 1,
   "standing": 1
  },
  {
   "id": "militia-spearman",
   "name": "Militia Spearman",
   "tier": 2,
   "role": "minion",
   "themes": [
    "soldier",
    "urban"
   ],
   "count": 3,
   "standing": 3
  },
  {
   "id": "hailstone-archer",
   "name": "Hailstone Archer",
   "tier": 2,
   "role": "artillery",
   "themes": [
    "soldier",
    "frost"
   ],
   "count": 1,
   "standing": 1
  }
 ],
 "budget": {
  "party_tier": 2,
  "party_size": 3,
  "difficulty": "hard",
  "points": 6.5,
  "target": "7.5-10.5",
  "band": "standard"
 },
 "tone": {
  "anchor": "Dread rising with the tide; the sea remembers every name",
  "genre": "cosmic horror",
  "humor": "rare",
  "consequence": "heavy"
 }
}

catalog, one threat per line:

drowned-deckhand|Drowned Deckhand|2|minion|undead coastal
glyph-turret|Glyph Turret|2|artillery|construct arcane
hailstone-archer|Hailstone Archer|2|artillery|soldier frost
howling-matriarch|Howling Matriarch|2|leader|beast wild
militia-spearman|Militia Spearman|2|minion|soldier urban
spitting-reef-eel|Spitting Reef Eel|2|artillery|beast coastal poison
spore-puffling|Spore Puffling|2|minion|plant poison
watch-sergeant|Watch Sergeant|2|leader|soldier urban
alley-slinger|Alley Slinger|1|artillery|urban bandit
bog-gnat-cloud|Bog Gnat Cloud|1|minion|swarm poison wild
clockwork-drone|Clockwork Drone|3|minion|construct arcane swarm
ditch-captain|Ditch Captain|1|leader|bandit soldier
ember-deacon|Ember Deacon|1|leader|cultist fire
ember-zealot|Ember Zealot|3|minion|cultist fire
free-company-captain|Free Company Captain|3|leader|soldier bandit
frostbound-thrall|Frostbound Thrall|3|minion|undead frost
gutter-rat|Gutter Rat|1|minion|beast urban swarm
hooded-initiate|Hooded Initiate|1|minion|cultist urban
lantern-wisp|Lantern Wisp|1|artillery|spirit arcane
magma-spitter|Magma Spitter|3|artillery|elemental fire cavern
plague-abbot|Plague Abbot|3|leader|cultist poison
rattling-levy|Rattling Levy|1|minion|undead soldier
starglass-magus|Starglass Magus|3|artillery|arcane aberration

Reply:

{
 "task": "retheme",
 "diagnosis": {
  "state": "genre-mismatch",
  "summary": "A town-watch patrol plays as a street fight, not cosmic horror where the sea is hunting the party, and at 6.5 points it sits below the hard band of 7.5-10.5."
 },
 "anchor": "Dread rising with the tide; the sea remembers every name",
 "swaps": [
  {
   "from": "watch-sergeant",
   "to": "plague-abbot",
   "reason": "A cultist leader suits a drowned chapel better than a watchman, and its higher tier raises the budget into the hard band."
  },
  {
   "from": "militia-spearman",
   "to": "drowned-deckhand",
   "reason": "Same minion role and tier, but undead and coastal - the sea sending back its dead."
  },
  {
   "from": "hailstone-archer",
   "to": "spitting-reef-eel",
   "reason": "Same artillery role and tier, swapping frost soldiers for a coastal beast striking from the flooded pews."
  }
 ],
 "budget_intent": "raise",
 "energy": {
  "scene_type": "combat",
  "level": "high",
  "delivery": "Open quiet and slow with dripping water and the smell of salt, then let the first attack come out of the dark water; keep descriptions brief and go silent after each bell note."
 },
 "opener": "The tide breathes in. Between the rotted pews, shapes rise from the black water, still dressed in sailcloth, and at the altar a hooded figure lifts the lantern as if to welcome them home."
}

retheme: an encounter that already fits - no swaps

Input:

{
 "task": "retheme",
 "party": "(a JSON string - decoded below)",
 "npcs": "(a JSON string - decoded below)",
 "encounter": "(a JSON string - decoded below)",
 "budget": "(a JSON string - decoded below)",
 "tone": "(a JSON string - decoded below)",
 "catalog": "(id|name|tier|role|themes lines - shown below)",
 "gm_note": "Talia's player has been quiet all evening. The cart holds the shrine bell the party promised to deliver.",
 "clipped": "catalog: sent 14 of 104 threats (roles artillery, leader, skirmisher; within one tier)"
}

The JSON-string fields, decoded:

{
 "party": [
  {
   "name": "Wren",
   "concept": "half-elf scout, bow",
   "hp": "8/22",
   "stress": "0/6",
   "conditions": [],
   "down": false
  },
  {
   "name": "Odo",
   "concept": "dwarf cleric of the hearth",
   "hp": "26/26",
   "stress": "0/6",
   "conditions": [],
   "down": false
  },
  {
   "name": "Brisk",
   "concept": "human duelist, loud",
   "hp": "4/20",
   "stress": "0/6",
   "conditions": [
    "prone (1)"
   ],
   "down": false
  },
  {
   "name": "Talia",
   "concept": "hedge-witch, shy apprentice",
   "hp": "16/16",
   "stress": "0/6",
   "conditions": [],
   "down": false
  }
 ],
 "npcs": [
  {
   "name": "Old Fenwick",
   "side": "ally",
   "note": "toll-keeper, owes the brigands money"
  }
 ],
 "encounter": [
  {
   "id": "ditch-captain",
   "name": "Ditch Captain",
   "tier": 1,
   "role": "leader",
   "themes": [
    "bandit",
    "soldier"
   ],
   "count": 1,
   "standing": 1
  },
  {
   "id": "toll-bridge-brigand",
   "name": "Toll Bridge Brigand",
   "tier": 1,
   "role": "skirmisher",
   "themes": [
    "bandit",
    "wild"
   ],
   "count": 2,
   "standing": 2
  },
  {
   "id": "alley-slinger",
   "name": "Alley Slinger",
   "tier": 1,
   "role": "artillery",
   "themes": [
    "urban",
    "bandit"
   ],
   "count": 1,
   "standing": 1
  }
 ],
 "budget": {
  "party_tier": 1,
  "party_size": 4,
  "difficulty": "standard",
  "points": 9,
  "target": "6-10",
  "band": "standard"
 },
 "tone": {
  "anchor": "Scrappy heroes, real danger, a little mud-splattered humour",
  "genre": "heroic fantasy",
  "humor": "bracketed",
  "consequence": "medium"
 }
}

catalog, one threat per line:

alley-slinger|Alley Slinger|1|artillery|urban bandit
ash-hound|Ash Hound|1|skirmisher|beast fire
ditch-captain|Ditch Captain|1|leader|bandit soldier
ember-deacon|Ember Deacon|1|leader|cultist fire
lantern-wisp|Lantern Wisp|1|artillery|spirit arcane
tidepool-snapper|Tidepool Snapper|1|skirmisher|beast coastal
toll-bridge-brigand|Toll Bridge Brigand|1|skirmisher|bandit wild
cinder-imp|Cinder Imp|2|skirmisher|fire elemental
glyph-turret|Glyph Turret|2|artillery|construct arcane
hailstone-archer|Hailstone Archer|2|artillery|soldier frost
howling-matriarch|Howling Matriarch|2|leader|beast wild
rooftop-cutpurse|Rooftop Cutpurse|2|skirmisher|bandit urban shadow
spitting-reef-eel|Spitting Reef Eel|2|artillery|beast coastal poison
watch-sergeant|Watch Sergeant|2|leader|soldier urban

Reply:

{
 "task": "retheme",
 "diagnosis": {
  "state": "fit",
  "summary": "Bandits at a toll bridge are classic heroic fantasy with room for mud and humour, and at 9 points the encounter sits inside the standard band of 6-10."
 },
 "anchor": "Scrappy heroes, real danger, a little mud-splattered humour",
 "swaps": [],
 "budget_intent": "hold",
 "energy": {
  "scene_type": "combat",
  "level": "high",
  "delivery": "Keep narration short and physical - mud, creaking planks, the river below - and save a breath of quiet for when the captain first demands the cart."
 },
 "opener": "Branches crack overhead and figures drop onto the bridge, boots slapping the wet planks. A captain in a dented helm plants himself before the cart and grins: the toll has just gone up."
}

The board checks; the API does not

Over the API you get the reply as JSON. Reconciling it is yours to do - and the page's own code is plain scripts you can load in node after global.window = {} (load hexmath.js, tablekit.js, threats.js, initiative.js, scenekit.js and facilitate.js in that order): Facilitate.buildInput(session, {task}) builds this exact run body, Facilitate.normalize(reply, {task}) reads a reply, Facilitate.reconcileBeats(result, session) checks every name and the spotlight against the tally, and Facilitate.reconcileRetheme(result, session) drops swaps whose ids are not in the encounter or the threat table and recomputes the budget with TableKit.budget(ids, byId, tier, partySize, difficulty).

Step 1 — a tiny client helper

One function that sends JSON and unwraps the envelope.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here"   # from https://turn-and-hex.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"turn-and-hex"}' | jq -r '.data.token')

Step 3 — GET /me

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

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

Step 4 — POST /estimate

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

# input.json: the run input object - {"task": "beats", "party": "[...]", "npcs": "[...]", "encounter": "[...]", "budget": "{...}", "tone": "{...}", "turn_state": "{...}", "spotlight": "{...}", "scenes": "[...]"}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses turn-and-hex:<task>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="turn-and-hex:beats:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}' 

Step 6 — POST /run-stream

The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.

# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: turn-and-hex:beats:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json