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:
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: asking the model is metered and needs a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. 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.
| task | When | Extra fields |
|---|---|---|
beats | Mid-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. |
retheme | Before 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).
| Field | What it holds |
|---|---|
task | "beats" or "retheme". |
party | JSON string: [{"name","concept","hp":"8/22","stress":"0/6","conditions":["prone (1)"],"down":false}]. |
npcs | JSON string: [{"name","side":"ally|foe","note"}] - the only other names the reply may use. |
encounter | JSON string: [{"id","name","tier":1,"role","themes":[...],"count":2,"standing":2}] - ids from the bundled threat table (threats.js, 104 original threats). |
budget | JSON 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). |
tone | JSON string: {"anchor","genre","humor":"integrated|bracketed|rare|none","consequence":"light|medium|heavy|brutal"}. |
turn_state | beats: {"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. |
spotlight | beats: {"heroes":[{"name","acted","spoke","scene_score","session_total"}],"lowest":["Talia"]}. |
scenes | beats: the last 5 scene markers, [{"title","kind","minute","note"}]; the last is the scene being played. |
catalog | retheme: candidate threats, one per line id|name|tier|role|themes - threats sharing a role with the encounter within one tier, at most 70. |
gm_note | Optional free text; over 600 characters the middle is cut with a marker. |
clipped | Optional: 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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://turn-and-hex.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// turn-and-hex.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://turn-and-hex.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://turn-and-hex.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// TurnAndHex.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class TurnAndHex {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://turn-and-hex.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://turn-and-hex.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://turn-and-hex.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://turn-and-hex.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
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')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "turn-and-hex"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "turn-and-hex" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "turn-and-hex"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "turn-and-hex"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "turn-and-hex" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "turn-and-hex"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "turn-and-hex" }))!["token"]!.GetValue<string>();
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}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["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}'
inp = json.load(open("input.json")) # {"task": "beats", "party": "[...]", "npcs": "[...]", "encounter": "[...]", "budget": "{...}", "tone": "{...}", "turn_state": "{...}", "spotlight": "{...}", "scenes": "[...]"}
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task, party, npcs, encounter, budget, tone, turn_state, spotlight, scenes}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
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}'
import hashlib, time
key = "turn-and-hex:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the lane's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("logline"))
import { createHash } from "node:crypto";
const key = `turn-and-hex:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.logline);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "turn-and-hex:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "turn-and-hex:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply);
require "digest"
key = "turn-and-hex:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["logline"]].inspect
$key = "turn-and-hex:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["logline"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"turn-and-hex:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
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
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "beats", "party": "[...]", "npcs": "[...]", "encounter": "[...]", "budget": "{...}", "tone": "{...}", "turn_state": "{...}", "spotlight": "{...}", "scenes": "[...]"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "turn-and-hex:beats:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `turn-and-hex:beats:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "turn-and-hex:beats:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "turn-and-hex:beats:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "turn-and-hex:beats:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: turn-and-hex:beats:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"turn-and-hex:beats:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);