← Plyquarry / API
Tokens

Drive Plyquarry from your own code

Both of the sandbox's asks are available over HTTP. A natural use is a game-design notebook, a classroom tool or a batch script that already generates rulesets and plays them out: it posts a ruleset with its simulation numbers and gets back a balance critique with concrete rule changes, or a rules card a person can print and play from.

One thing to be clear about first: the model never sees a move or a game tree. In the browser, the ruleset is built from the controls (board, pieces drawn on the move stencil, setup, phases, captures, win conditions, turn structure, ply limit) and the summary comes from seeded engine-vs-engine auto-play run on the page. Over the API there is no board and no simulator: you build the same shapes yourself - the compact ruleset JSON, the summary statistics, the lint list and the field catalogue - and send them as strings. The model returns a critique or a card; the caller checks every number and every change against its own ruleset and summary before showing anything. The page'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":"plyquarry"}), 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 is answered as critique, with "task":"critique" in the reply.

taskWhat you getFields it leans on
critiqueA balance critique: a verdict (promising, lopsided, drawish, degenerate, too-short, too-long, broken), a one-sentence headline, 2-5 findings (first-move advantage, dead rules, degenerate strategies, win conditions too easy or impossible, drawishness, length, clarity) each pinned to a summary statistic with its exact value, 1-5 changes (a field path from fields and a new legal value), what to watch after re-simulating, and a compare against the previous round.ruleset, summary, lint, fields, previous, designer_note.
sheetA rules card a person could print and play from without the app: title, tagline, components, setup, the turn, how each piece moves, captures, the win conditions that are on and no others, the ending (ply limit, passing), 1-3 tips and one line of simulation results.ruleset (and summary for the stats line and tips).

Every run body is a JSON object of string fields (the declared input schema). The five structured fields are JSON-encoded strings, not nested objects.

FieldWhat it holds
task"critique" or "sheet".
rulesetJSON string: {"name","board":{"shape":"square|hex","w","h"},"pieces":[{"id","name","dirs":["N","NE",...],"range":1,"leaps":[[1,2]]}],"setup":{"mode":"empty|rows|rows-alt","rows","piece"},"phase":{"mode":"move|place|place-then-move","reserve","piece"},"capture":{"replace","jump","custodial","forced"},"win":{"connect":{"on","n"},"reach":{"on"},"capture_all":{"on"},"majority":{"on"},"immobilise":{"on"}},"turn":{"actions","first_actions","p2_bonus"},"limits":{"max_plies","on_limit":"draw|majority"}}. Directions are as seen by the first player (ink, moving "N" = up the board); the second player (bone) uses the same pattern rotated 180 degrees. A hex board is an axial rhombus with six neighbours per cell.
summaryEmpty string on a sheet ask made before any auto-play (the card then has no stats line); otherwise a JSON string: {"games","engines":["greedy","greedy"],"seed","max_plies","p1_win_rate","p2_win_rate","draw_rate","first_player_edge","p1_share_of_decisive","avg_plies","median_plies","min_plies","max_plies_seen","length_hist":[[lo,hi,games]],"decider_share":{"connect","reach","capture_all","majority","immobilise","limit","stalemate"},"captures_per_game":{"replace","jump","custodial"},"avg_branching","complete"}. Rates and shares are 0..1 over all games; first_player_edge = p1_win_rate - p2_win_rate.
lintJSON string: an array of problems already found in the ruleset, each "err: ...", "warn: ..." or "note: ..."; "[]" when clean.
fieldsJSON string: an array of the only fields a change may target, each "path (type range-or-values) = current", e.g. "win.connect.n (int 3..8) = 4", scoped to this ruleset's pieces.
previousEmpty string on a first critique. On a re-critique, a JSON string with the earlier summary and the changes the designer accepted; the reply's compare then says what moved.
designer_noteFree text: audience, feel, length. May be empty; over 600 characters it is cut at a sentence boundary with a marker.
clippedWhat was cut before sending, or "nothing clipped; move logs and game trees are never sent".
retry_noteOptional. Sent only on a retry after a reply that was not valid JSON; tells the model what was wrong.

The reply text is one JSON object:

// task "critique"
{ "task": "critique",
  "verdict": "promising|lopsided|drawish|degenerate|too-short|too-long|broken",
  "headline": "...",
  "findings": [{ "kind": "first-move-advantage|dead-rule|degenerate-strategy|too-easy|impossible|drawish|length|clarity|other",
                 "title": "...", "detail": "...", "stat": "first_player_edge", "value": -1 }],
  "changes": [{ "field": "turn.first_actions", "value": 2, "reason": "...", "targets": "finding title" }],
  "watch": ["..."],
  "compare": "" }

// task "sheet"
{ "task": "sheet", "title": "...", "tagline": "...", "players": "Two players",
  "components": ["..."], "setup": ["..."], "turn": ["..."],
  "pieces": [{ "piece": "Stone", "moves": "..." }],
  "capture": ["..."],
  "winning": [{ "condition": "connect|reach|capture_all|majority|immobilise", "text": "..." }],
  "ending": ["..."], "tips": ["..."], "stats_line": "..." }

Check before you show it: a finding's stat must be a statistic the summary has and its value must match within 0.03 on rates; a change's field must be copied from fields with a legal value; a rules card must cover exactly the ruleset's pieces and win conditions. The full list is below the examples.

The model behind both tasks is gpt-terra. Credits are reserved up front (hold_credits from /estimate) and the run is then charged for what it actually used.

Worked examples

These are the bundled Hex Lines example on the page: the exact run body the page builds (ruleset from the preset's controls, summary from 60 seeded greedy-vs-greedy games, seed 42) and the reply produced against the shipped prompt (the page replays the same replies for free).

critique: Hex Lines, a family placement game where the second player won all 60 simulated games

Input (previous is empty: a first critique, so the reply's compare is empty too):

{
 "task": "critique",
 "ruleset": "(a JSON string - decoded below)",
 "summary": "(a JSON string - decoded below)",
 "lint": "(a JSON string - decoded below)",
 "fields": "(a JSON string - decoded below)",
 "previous": "",
 "designer_note": "Family holiday game. We like making lines; the sandwich capture was my nephew's idea.",
 "clipped": "nothing clipped; move logs and game trees are never sent"
}

ruleset, decoded:

{
 "name": "Hex Lines",
 "board": {
  "shape": "hex",
  "w": 7,
  "h": 7
 },
 "pieces": [
  {
   "id": "a",
   "name": "Stone",
   "dirs": [
    "NW",
    "NE",
    "W",
    "E",
    "SW",
    "SE"
   ],
   "range": 1
  }
 ],
 "setup": {
  "mode": "empty",
  "rows": 1,
  "piece": "a"
 },
 "phase": {
  "mode": "place",
  "reserve": 25,
  "piece": "a"
 },
 "capture": {
  "replace": false,
  "jump": false,
  "custodial": true,
  "forced": false
 },
 "win": {
  "connect": {
   "on": true,
   "n": 4
  },
  "reach": {
   "on": false
  },
  "capture_all": {
   "on": false
  },
  "majority": {
   "on": true
  },
  "immobilise": {
   "on": false
  }
 },
 "turn": {
  "actions": 2,
  "first_actions": 1,
  "p2_bonus": 0
 },
 "limits": {
  "max_plies": 60,
  "on_limit": "majority"
 }
}

summary, decoded:

{
 "games": 60,
 "engines": [
  "greedy",
  "greedy"
 ],
 "seed": 42,
 "max_plies": 60,
 "p1_win_rate": 0,
 "p2_win_rate": 1,
 "draw_rate": 0,
 "first_player_edge": -1,
 "p1_share_of_decisive": 0,
 "avg_plies": 50,
 "median_plies": 50,
 "min_plies": 50,
 "max_plies_seen": 50,
 "length_hist": [
  [
   49,
   56,
   60
  ]
 ],
 "decider_share": {
  "connect": 0,
  "reach": 0,
  "capture_all": 0,
  "majority": 1,
  "immobilise": 0,
  "limit": 0,
  "stalemate": 0
 },
 "captures_per_game": {
  "replace": 0,
  "jump": 0,
  "custodial": 44.9
 },
 "avg_branching": 44.8,
 "complete": true
}

lint, decoded:

[]

fields, decoded:

board.shape (enum: square|hex) = "hex"
board.w (int 4..10) = 7
board.h (int 4..10) = 7
setup.mode (enum: empty|rows|rows-alt) = "empty"
setup.rows (int 1..3) = 1
setup.piece (piece: a) = "a"
phase.mode (enum: move|place|place-then-move) = "place"
phase.reserve (int 1..60) = 25
phase.piece (piece: a) = "a"
capture.replace (bool) = false
capture.jump (bool) = false
capture.custodial (bool) = true
capture.forced (bool) = false
win.connect.on (bool) = true
win.connect.n (int 3..8) = 4
win.reach.on (bool) = false
win.capture_all.on (bool) = false
win.majority.on (bool) = true
win.immobilise.on (bool) = false
turn.actions (int 1..2) = 2
turn.first_actions (int 1..2) = 1
turn.p2_bonus (int 0..3) = 0
limits.max_plies (int 20..400) = 60
limits.on_limit (enum: draw|majority) = "majority"
pieces.a.range (int 1..10) = 1
pieces.a.dirs (dirs: NW NE W E SW SE) = ["NW","NE","W","E","SW","SE"]
pieces.a.leaps (leaps: [dx,dy] at distance 2) = []

Reply:

{
 "task": "critique",
 "verdict": "lopsided",
 "headline": "The second player won all 60 simulated games on stone count when the board filled, and four in a row never decided a single game because sandwich captures keep breaking lines up.",
 "findings": [
  {
   "kind": "first-move-advantage",
   "title": "Second player always wins",
   "detail": "The second player won every game (first_player_edge -1). The first player places one stone on the first turn and then both place two, so the second player is always a stone ahead and also gets the last double turn before the board fills; with majority deciding, that lead is what counts. The engines are simple one-move-ahead players, but a clean sweep is hard to blame on weak play alone.",
   "stat": "first_player_edge",
   "value": -1
  },
  {
   "kind": "dead-rule",
   "title": "Four in a row never wins",
   "detail": "Connect decided no games at all (decider_share.connect 0); every game ended by majority (decider_share.majority 1). On a hex board where a stone sits in six lines, any line of three is easy to sandwich, so lines are broken before they reach four. The line-making the family enjoys is not what wins.",
   "stat": "decider_share.connect",
   "value": 0
  },
  {
   "kind": "degenerate-strategy",
   "title": "Sandwich captures dominate",
   "detail": "There were 44.9 custodial captures per game, roughly one for every stone placed. The best plan is to sandwich, not to build, so the board churns until it fills.",
   "stat": "captures_per_game.custodial",
   "value": 44.9
  },
  {
   "kind": "length",
   "title": "Every game runs to the same length",
   "detail": "All games ended at 50 plies (min_plies and max_plies_seen both 50), when the board filled. A fixed length means there is no sudden finish to play for.",
   "stat": "median_plies",
   "value": 50
  }
 ],
 "changes": [
  {
   "field": "turn.first_actions",
   "value": 2,
   "reason": "Gives the first player two placements on the first turn too, removing the stone the second player is always ahead by.",
   "targets": "Second player always wins"
  },
  {
   "field": "win.connect.n",
   "value": 5,
   "reason": "With equal turns four in a row comes too fast; five keeps lines possible while leaving time for sandwiches.",
   "targets": "Four in a row never wins"
  }
 ],
 "watch": [
  "first_player_edge should move from -1 towards 0",
  "decider_share.connect should rise above 0 so lines start winning games",
  "captures_per_game.custodial should fall below 44.9"
 ],
 "compare": ""
}

sheet: the printable rules card for the same ruleset

Input - the same fields with task set to sheet; the card is written from ruleset, and only stats_line and the tips draw on summary:

{
 "task": "sheet",
 "ruleset": "(a JSON string - decoded below)",
 "summary": "(a JSON string - decoded below)",
 "lint": "(a JSON string - decoded below)",
 "fields": "(a JSON string - decoded below)",
 "previous": "",
 "designer_note": "Family holiday game. We like making lines; the sandwich capture was my nephew's idea.",
 "clipped": "nothing clipped; move logs and game trees are never sent"
}

ruleset, decoded:

{
 "name": "Hex Lines",
 "board": {
  "shape": "hex",
  "w": 7,
  "h": 7
 },
 "pieces": [
  {
   "id": "a",
   "name": "Stone",
   "dirs": [
    "NW",
    "NE",
    "W",
    "E",
    "SW",
    "SE"
   ],
   "range": 1
  }
 ],
 "setup": {
  "mode": "empty",
  "rows": 1,
  "piece": "a"
 },
 "phase": {
  "mode": "place",
  "reserve": 25,
  "piece": "a"
 },
 "capture": {
  "replace": false,
  "jump": false,
  "custodial": true,
  "forced": false
 },
 "win": {
  "connect": {
   "on": true,
   "n": 4
  },
  "reach": {
   "on": false
  },
  "capture_all": {
   "on": false
  },
  "majority": {
   "on": true
  },
  "immobilise": {
   "on": false
  }
 },
 "turn": {
  "actions": 2,
  "first_actions": 1,
  "p2_bonus": 0
 },
 "limits": {
  "max_plies": 60,
  "on_limit": "majority"
 }
}

summary, decoded:

{
 "games": 60,
 "engines": [
  "greedy",
  "greedy"
 ],
 "seed": 42,
 "max_plies": 60,
 "p1_win_rate": 0,
 "p2_win_rate": 1,
 "draw_rate": 0,
 "first_player_edge": -1,
 "p1_share_of_decisive": 0,
 "avg_plies": 50,
 "median_plies": 50,
 "min_plies": 50,
 "max_plies_seen": 50,
 "length_hist": [
  [
   49,
   56,
   60
  ]
 ],
 "decider_share": {
  "connect": 0,
  "reach": 0,
  "capture_all": 0,
  "majority": 1,
  "immobilise": 0,
  "limit": 0,
  "stalemate": 0
 },
 "captures_per_game": {
  "replace": 0,
  "jump": 0,
  "custodial": 44.9
 },
 "avg_branching": 44.8,
 "complete": true
}

lint, decoded:

[]

fields, decoded:

board.shape (enum: square|hex) = "hex"
board.w (int 4..10) = 7
board.h (int 4..10) = 7
setup.mode (enum: empty|rows|rows-alt) = "empty"
setup.rows (int 1..3) = 1
setup.piece (piece: a) = "a"
phase.mode (enum: move|place|place-then-move) = "place"
phase.reserve (int 1..60) = 25
phase.piece (piece: a) = "a"
capture.replace (bool) = false
capture.jump (bool) = false
capture.custodial (bool) = true
capture.forced (bool) = false
win.connect.on (bool) = true
win.connect.n (int 3..8) = 4
win.reach.on (bool) = false
win.capture_all.on (bool) = false
win.majority.on (bool) = true
win.immobilise.on (bool) = false
turn.actions (int 1..2) = 2
turn.first_actions (int 1..2) = 1
turn.p2_bonus (int 0..3) = 0
limits.max_plies (int 20..400) = 60
limits.on_limit (enum: draw|majority) = "majority"
pieces.a.range (int 1..10) = 1
pieces.a.dirs (dirs: NW NE W E SW SE) = ["NW","NE","W","E","SW","SE"]
pieces.a.leaps (leaps: [dx,dy] at distance 2) = []

Reply:

{
 "task": "sheet",
 "title": "Hex Lines",
 "tagline": "Build lines of four on a hex board, but watch out: any stone caught in a sandwich is gone.",
 "players": "Two players",
 "components": [
  "board 7 x 7 (hex cells, a rhombus where each cell touches six others)",
  "25 Stones per player (ink and bone)"
 ],
 "setup": [
  "Start with an empty board.",
  "Each player takes 25 Stones in their own colour as a reserve. Ink is the first player."
 ],
 "turn": [
  "Each turn you make two actions.",
  "The first player's very first turn is only one action.",
  "An action is placing one Stone from your reserve on any empty cell. There is no moving: Stones stay where they are placed unless captured."
 ],
 "pieces": [
  {
   "piece": "Stone",
   "moves": "Stones do not move. Each action places one Stone from your reserve on any empty cell."
  }
 ],
 "capture": [
  "Sandwich capture: when your newly placed Stone closes off a straight line of enemy Stones with another of your Stones at the far end, along any of the six hex lines, all the enemy Stones in between are removed."
 ],
 "winning": [
  {
   "condition": "connect",
   "text": "Make a straight line of 4 of your Stones in a row along any of the six hex directions and you win at once."
  },
  {
   "condition": "majority",
   "text": "When the board is full, or nobody can place, the player with more Stones on the board wins."
  }
 ],
 "ending": [
  "The game is limited to 60 plies (single placements, counting both players). If it reaches that limit, the player with more Stones on the board wins.",
  "If a player cannot place, they pass; two passes in a row end the game and it is decided by who has more Stones."
 ],
 "tips": [
  "A Stone sits on six lines, so a line of three is easy to sandwich; protect both ends before you extend.",
  "Two placements a turn let you close a sandwich and start a new line at once.",
  "In simulation the second player won every game on stone count, so as the first player go for four in a row rather than waiting for the board to fill."
 ],
 "stats_line": "In 60 simulated games the second player won 100% and the first player 0%, every game going to 50 plies and being decided by who had more Stones."
}

The page checks; the API does not

Over the API you get the reply as JSON. Reconciling it is yours to do, the way the page does it:

The page's own code is plain scripts you can load in node after global.window = {} (load rules.js, engine.js, sim.js and critique.js in that order): Sim.runSync(rules, {games: 60, engines: ["greedy", "greedy"], seed: 42}) reproduces the summary, Critique.buildInput({rules, summary, note}, {task}) builds this exact run body, Critique.normalize(reply, {task}) reads a reply, and Critique.reconcile(result, {rules, summary}) runs the checks above.

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://plyquarry.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":"plyquarry"}' | 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: the run holds that many credits up front and is charged only for what it actually uses. min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "critique", "ruleset": "{...}", "summary": "{...}", "lint": "[...]", "fields": "[...]", "previous": "", "designer_note": "...", "clipped": "..."}
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 plyquarry:<task>:<inputHash>:a<attempt>. The job's output.output is the reply text; charged_credits is the actual charge; truncated: true means the balance cut it short. If the reply is not valid JSON, the page retries once with a retry_note field added (attempt a2).

KEY="plyquarry:critique:$(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: plyquarry:critique:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json