← Steal or Open
API
Tokens

Drive Steal or Open from your own code

The one metered task, rules-check, is available over HTTP. It takes the number of players, the house rules as numbered lines, the page's word-list reading of them and its simulated figures, and returns a verdict - rewrite, patch or ready - with the lines that clash, loopholes walked step by step, the topics that are missing, a numbered table card (fixes marked as suggestions) and a host script to read aloud. A natural use is a party-planning tool that checks the rules a host typed before the invitations go out, or a club newsletter that prints a reviewed card every year.

The division of labour first: the page owns the counting and the simulation; the model owns the judgement and the words; the page re-checks the reply. Splitting the rules into lines, the topic reading, the steal limit, the steal-back ban, the per-turn cap, the first player's final turn and 400 seeded games come from swapkit.js, which runs in node as well as in the browser. The model does no arithmetic: SwapKit.buildInput hands it the figures. SwapKit.check holds each reply to the lines: every cited line real, the verdict following the reply's own lists, the card keeping the rules' price and steal limit, no unmarked number the rules lack, and no line id in prose.

Get a token on the token page (step 2 below also shows how to mint a guest token in code).

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":"steal-or-open"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field (task, players, rules) is missing.
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: the review 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 and the verdicts

Every run sends task: rules-check (the only task; a missing or unknown one is treated as rules-check). The verdict follows a fixed check order - the first that applies wins:

OrderVerdictWhen
1rewriteTwo or more lines clash, or no line says what a player does on a turn.
2patchOtherwise, a core topic is missing (turn order, price cap, steal limit, how it ends) or there is at least one loophole.
3readyOtherwise. Missing extras (stealing straight back, steals per turn, wrapping, showing opened gifts, after the game) are allowed.

The declared input schema carries scalars only, so the structured fields (rules, page_reading, page_sim) travel JSON-encoded as strings (SwapKit.toWire does this). Every value is a string.

Input fields

FieldTypeNotes
taskstringrules-check. Required.
playersstringHow many people will play, 3 to 40. Required.
rulesstring (JSON)[{id, text}] - ids R1.., one per line typed, at most 30 lines of at most 280 characters. Required.
page_readingstring (JSON){covered, missing_core, steal_limits_found, steal_back_barred, turn_cap, first_player_final_turn, price, endless_chain} - the page's word-list reading, a hint the model judges for itself.
page_simstring (JSON){games, players, median_minutes, p90_minutes, median_steals, p90_steals, median_longest_turn, max_longest_turn, mean_frozen, runaway_games, assumptions} - simulated figures; minutes are estimates.
clip_note, retry_notestringWhat the page cut, when it cut something; and only on a reformat retry, what was wrong.

The reply

The job's output.output is a string holding one JSON object: task, verdict, title, summary, clashes ([{refs, why, fix}]), loopholes ([{refs, scenario, fix}]), gaps ([{topic, why, fix}]), card ([{text, refs, suggested}], at most 10) and host_script. Parse with SwapKit.parseReply, then normalize(reply), then check(reply, built) where built = SwapKit.buildInput(form); md(reply, built) writes the Markdown and cardAsRules(reply) turns the card into new rules for a second check.

Worked examples

Replies produced against the app's prompt for the three sample rule sets (made up for this app; long fields shortened; the full replies ship in example-result.js). Together they cover all three verdicts.

sixteen players, two lines with different steal limits - rewrite

Input:

{
 "task": "rules-check",
 "players": "16",
 "rules": "[{\"id\":\"R1\",\"text\":\"Office Gift Swap - Friday after lunch\"},{\"id\":\"R2\",\"text\":\"Bring one wrapped gift, $25 max.\"},{\"id\":\"R3\",\"text\":\"Everyone draws a number from the hat; number 1 goes first.\"},{\"id\":\"R4\",\"text\":\"On your turn, open a wrapped gift from the pile or steal one somebody already opened.\"},{\"id\":\"R5\",\"text\":\"If your gift gets stolen, you can open a new one or steal from someone else.\"},{\"id\":\"R6\",\"text\":\"A  ... (683 characters)",
 "page_reading": "{\"covered\":[{\"topic\":\"turn\",\"refs\":[\"R4\",\"R5\"]},{\"topic\":\"order\",\"refs\":[\"R3\"]},{\"topic\":\"price\",\"refs\":[\"R2\"]},{\"topic\":\"steal_limit\",\"refs\":[\"R6\",\"R8\"]},{\"topic\":\"ending\",\"refs\":[\"R9\"]},{\"topic\":\"steal_back\",\"refs\":[\"R7\"]},{\"topic\":\"wrapping\",\"refs\":[\"R2\"]}],\"missing_core\":[],\"steal_limits_found\":[{\"steals\":2,\"ref\":\"R6\"},{\"steals\":3,\"ref\":\"R8\"}],\"steal_back_barred\":true,\"turn_cap\":\"not stated\",\"first_player_final_t ... (474 characters)",
 "page_sim": "{\"games\":400,\"players\":16,\"appetite\":\"usual\",\"median_minutes\":21,\"p90_minutes\":23,\"median_steals\":11,\"p90_steals\":17,\"median_longest_turn\":3,\"max_longest_turn\":9,\"mean_frozen\":4.1,\"runaway_games\":0,\"assumptions\":\"estimates only: about 60 seconds per unwrap and 25 seconds per steal; a player steals 45% of the time when a steal is allowed\"}"
}

Reply:

{
 "task": "rules-check",
 "verdict": "rewrite",
 "title": "Two different steal limits conflict",
 "summary": "Two lines set different steal limits for the same gift, so a host must guess which one to enforce. Keep the first one listed and drop the other before play. A game runs about 21 minutes, the page's estimate.",
 "clashes": [
  {
   "refs": [
    "R6",
    "R8"
   ],
   "why": "One line freezes a gift after it's stolen twice; another says a third steal happens before locking it. A host cannot follow both rules at once.",
   "fix": "Keep the rule that freezes a gift after it's stolen twice (the one listed first); remove the third-steal rule."
  }
 ],
 "loopholes": [],
 "gaps": [],
 "card": [
  {
   "text": "Bring one wrapped gift, $25 max.",
   "refs": [
    "R2"
   ],
   "suggested": false
  },
  {
   "text": "Draw a number from the hat; number 1 goes first.",
   "refs": [
    "R3"
   ],
   "suggested": false
  },
  {
   "text": "On your turn, open a wrapped gift from the pile, or steal one that's already opened.",
   "refs": [
    "R4"
   ],
   "suggested": false
  },
  {
   "text": "If your gift is stolen, open a new gift or steal from someone else right away.",
   "refs": [
    "R5"
   ],
   "suggested": false
  },
  "... (7 card rules in all)"
 ],
 "host_script": "Each of you brought one wrapped gift, $25 max. Draw a number from the hat - number 1 goes first. On your turn, open a wrapped gift from the pile, or steal one that's already been opened. If your gift gets stolen, take your turn right away: open a new gift or steal from someone else. You can't steal back the exact gift that was just taken from you. Once a gift has been stolen twice, it freezes - no one can take it again. The game ends the moment the last wrapped gift is opened."
}

nine players, no price cap and no ending - patch

Input:

{
 "task": "rules-check",
 "players": "9",
 "rules": "[{\"id\":\"R1\",\"text\":\"Our family gift swap\"},{\"id\":\"R2\",\"text\":\"Everyone brings one wrapped present.\"},{\"id\":\"R3\",\"text\":\"Youngest goes first, then we go around the circle to the left.\"},{\"id\":\"R4\",\"text\":\"When it's your turn you can unwrap a present or steal one that's already open.\"},{\"id\":\"R5\",\"text\":\"If someone steals yours, you get to go again right away.\"},{\"id\":\"R6\",\"text\":\"Each present can only be stolen 3 time ... (542 characters)",
 "page_reading": "{\"covered\":[{\"topic\":\"turn\",\"refs\":[\"R4\"]},{\"topic\":\"order\",\"refs\":[\"R3\"]},{\"topic\":\"steal_limit\",\"refs\":[\"R6\"]},{\"topic\":\"wrapping\",\"refs\":[\"R2\"]},{\"topic\":\"reveal\",\"refs\":[\"R7\"]}],\"missing_core\":[\"price\",\"ending\"],\"steal_limits_found\":[{\"steals\":3,\"ref\":\"R6\"}],\"steal_back_barred\":\"not stated\",\"turn_cap\":\"not stated\",\"first_player_final_turn\":\"not stated\",\"price\":\"not found\",\"endless_chain\":false}",
 "page_sim": "{\"games\":400,\"players\":9,\"appetite\":\"greedy\",\"median_minutes\":14,\"p90_minutes\":17,\"median_steals\":13,\"p90_steals\":20,\"median_longest_turn\":4,\"max_longest_turn\":14,\"mean_frozen\":3.5,\"runaway_games\":0,\"assumptions\":\"estimates only: about 60 seconds per unwrap and 25 seconds per steal; a player steals 65% of the time when a steal is allowed\"}"
}

Reply:

{
 "task": "rules-check",
 "verdict": "patch",
 "title": "No price cap, no ending rule, and an order gap",
 "summary": "The rules cover a turn, order, and freezing, but never say when the game ends or set a spending cap, and don't say who goes next after an immediate extra turn. About 14 minutes per game, the page's estimate.",
 "clashes": [],
 "loopholes": [
  {
   "refs": [
    "R3",
    "R5"
   ],
   "scenario": "A player out of normal turn order steals a gift; the player who lost it immediately takes another turn, as the line about going again says; once that turn ends, nothing says whether the circle resumes after the original stealer or elsewhere.",
   "fix": "After an extra turn like this, play returns to whoever comes next after the player who took that extra turn, continuing the usual circle order."
  }
 ],
 "gaps": [
  {
   "topic": "price",
   "why": "No line sets a spending cap, a homemade-only rule, or any other limit on what a brought gift can be.",
   "fix": "Agree on a spending limit for gifts and note it on the card."
  },
  {
   "topic": "ending",
   "why": "No line says when the game is over, such as after the last wrapped gift is opened or a final round.",
   "fix": "The game ends once every wrapped gift has been unwrapped; whoever is holding a gift at that point keeps it."
  },
  {
   "topic": "steal_back",
   "why": "No line says whether a player can immediately steal back the very gift that was just taken from them.",
   "fix": "Add a line that a player may not immediately steal back the gift just taken from them on the same turn."
  },
  {
   "topic": "turn_cap",
   "why": "No line limits how many times steals can chain together in a row before order returns to the circle.",
   "fix": "Cap chained steals at 3 in a row before the extra turns stop and normal order resumes."
  }
 ],
 "card": [
  {
   "text": "Everyone brings one wrapped gift.",
   "refs": [
    "R2"
   ],
   "suggested": false
  },
  {
   "text": "Agree on a spending limit for gifts, and note it on the card.",
   "refs": [],
   "suggested": true
  },
  {
   "text": "The youngest player goes first, then play moves left around the circle.",
   "refs": [
    "R3"
   ],
   "suggested": false
  },
  {
   "text": "On your turn, unwrap a gift or steal one that's already open.",
   "refs": [
    "R4"
   ],
   "suggested": false
  },
  "... (10 card rules in all)"
 ],
 "host_script": "Each of you brought one wrapped gift, so let's get started. The youngest player goes first, then we move left around the circle. On your turn, unwrap a gift or steal one that's already open. If your gift gets stolen, you immediately take another turn - but you may not steal that same gift straight back. Chained steals like that stop after 3 in a row, then we go back to the usual order. Each gift can be stolen up to three times; after the third steal, it freezes with whoever is holding it. Hold your gift up so everyone can see what's open. We'll keep going until every wrapped gift has been opened."
}

fourteen players, every core topic covered - ready

Input:

{
 "task": "rules-check",
 "players": "14",
 "rules": "[{\"id\":\"R1\",\"text\":\"Neighbourhood gift swap - house rules\"},{\"id\":\"R2\",\"text\":\"Bring one wrapped gift worth no more than $20. Wrap it so the shape doesn't give it away.\"},{\"id\":\"R3\",\"text\":\"Draw numbered slips from a bowl; play goes in number order, starting with number 1.\"},{\"id\":\"R4\",\"text\":\"On your turn, either unwrap a gift from the pile or steal any opened gift that is not frozen.\"},{\"id\":\"R5\",\"text\":\"If your gi ... (1302 characters)",
 "page_reading": "{\"covered\":[{\"topic\":\"turn\",\"refs\":[\"R4\",\"R5\"]},{\"topic\":\"order\",\"refs\":[\"R3\"]},{\"topic\":\"price\",\"refs\":[\"R2\"]},{\"topic\":\"steal_limit\",\"refs\":[\"R4\",\"R7\",\"R10\"]},{\"topic\":\"ending\",\"refs\":[\"R10\",\"R11\"]},{\"topic\":\"steal_back\",\"refs\":[\"R6\"]},{\"topic\":\"turn_cap\",\"refs\":[\"R8\"]},{\"topic\":\"wrapping\",\"refs\":[\"R2\"]},{\"topic\":\"reveal\",\"refs\":[\"R9\"]},{\"topic\":\"after\",\"refs\":[\"R11\"]}],\"missing_core\":[],\"steal_limits_found\":[{\"ste ... (545 characters)",
 "page_sim": "{\"games\":400,\"players\":14,\"appetite\":\"usual\",\"median_minutes\":18,\"p90_minutes\":20,\"median_steals\":10,\"p90_steals\":14,\"median_longest_turn\":3,\"max_longest_turn\":3,\"mean_frozen\":1.4,\"runaway_games\":0,\"assumptions\":\"estimates only: about 60 seconds per unwrap and 25 seconds per steal; a player steals 45% of the time when a steal is allowed\"}"
}

Reply:

{
 "task": "rules-check",
 "verdict": "ready",
 "title": "Ready for the table",
 "summary": "The turn, order, price, freezing, and ending rules are all clear and consistent for 14 players, with no clashes or stalls. Expect about 18 minutes, the page's estimate, to play through.",
 "clashes": [],
 "loopholes": [],
 "gaps": [],
 "card": [
  {
   "text": "Bring one wrapped gift worth no more than $20, wrapped so its shape doesn't give it away.",
   "refs": [
    "R2"
   ],
   "suggested": false
  },
  {
   "text": "Draw a numbered slip to set play order, starting with the first player.",
   "refs": [
    "R3"
   ],
   "suggested": false
  },
  {
   "text": "On a turn, a player unwraps a new gift from the pile or steals an opened gift that isn't frozen.",
   "refs": [
    "R4"
   ],
   "suggested": false
  },
  {
   "text": "A player whose gift is stolen immediately takes a turn: unwrap a new gift or steal a different opened gift, unless the per-turn steal cap has been reached.",
   "refs": [
    "R5"
   ],
   "suggested": false
  },
  "... (10 card rules in all)"
 ],
 "host_script": "Each of you brought one wrapped gift worth no more than $20, wrapped so its shape doesn't give it away. Draw a numbered slip for play order, starting with the first player. On your turn, unwrap a new gift or steal an unfrozen opened gift. If you're stolen from, take your turn right away: unwrap, or steal a different gift, unless the steal cap is reached. You can't steal back the gift taken from you that turn. A gift freezes after its third steal. Only three steals chain before the next player must unwrap. Once the last gift is unwrapped, the first player keeps their gift or swaps for any unfrozen one, then the game ends. No trades after."
}

Build the input yourself

Load the script in node (global.window = global; require("./swapkit.js")), then:

const K = SwapKit;
const form = K.form({ players: "10", appetite: "usual",
  rules: "Bring one wrapped gift, $15 max.\nOn your turn, open a gift or steal an opened one.\nA gift freezes after two steals." });
const r = K.read(form);                       // numbered lines, topic reading, 400 simulated games
const built = K.buildInput(form);
const wire = K.toWire(built.input);           // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes                // the page's re-check of the reply
// K.cardAsRules(reply)                       // the card as new rules, for a second check

Step 1 — a tiny client helper

One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here"   # from https://steal-or-open.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":"steal-or-open"}' | 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, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "rules-check", "players": "9", "rules": "[{\"id\":\"R1\",\"text\":\"Our family gift swap\"},{\"id\":\"R2\",\"text\":\"Everyone brings one wrapped present.\"}, ...]", "page_reading": "{...}", "page_sim": "{...}"}
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 steal-or-open:<task>:<inputHash>:a<attempt> (the one task is rules-check), and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="steal-or-open:rules-check:$(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: steal-or-open:rules-check:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json