← Staggered Start
API

Drive Staggered Start from your own code

The one metered task, level, is available over HTTP. It takes the game, how a game is won, an optional target, how many games were played together, player_count and players - one numbered line per player with name, wins and note, exactly as the page writes it. It returns one entry per player (head-start size, a handicap in the game's own terms and the reason), a verdict for the table, one table rule and, when a gap is too wide, another way to play - or a polite decline when money rides on the game.

The division of labour first: the page works out its own head-start size per player by a fixed rule; the model fits a handicap to the game and writes the words; the page then re-checks every size, the verdict, every amount (a share of the target, rounded) and the word limits. Build players the same way (see "Build the input yourself") so the lines match.

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":"staggered-start"}), 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, game, win_rule, target, games_played, player_count, players) 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: levelling a game 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, the status and the verdicts

Every run sends task: level (the one task is level); a missing or different one is still answered as level. status is declined when the game, the win rule or any note mentions a bet, wagers, stakes, gambling or money riding on the result. Otherwise each player gets a tier from r = (leader's wins + 1) / (player's wins + 1): none below 1.5, small (10%) below 2.5, medium (20%) below 4, large (30%) below 7, beyond at 7 or more. The table's verdict is checked in this order, stopping at the first match: switch (any player beyond), stagger (any large), nudge (any small or medium), level (everyone none).

Input (every field is a string)

FieldRequiredWhat it holds
taskyesAlways level.
gameyesThe game, up to 40 characters.
win_ruleyesHow a game is won, up to 80 characters.
targetyesThe score or rounds needed to win as a whole number, or not given.
games_playedyesHow many games the group played together, 1 to 99.
player_countyesHow many players, 2 to 6, as text.
playersyesOne numbered line per player, fields separated by | : the name (the person's own words, up to 24 characters, or Player N), wins: (a whole number) and note: (up to 80 characters, or none), worded exactly as the page writes them.
retry_notenoOnly on the page's one reformat retry, after an unusable reply.

Reply (one JSON object)

{"task": "level", "status": "ok", "reason": "", "verdict": "nudge",
 "players": [{"n": 1, "tier": "none", "handicap": "", "why": "..."}, {"n": 2, "tier": "small", "handicap": "...", "why": "..."}, ...],
 "house_rule": "...", "switch_idea": "", "closing": "..."}
FieldWhat it holds
status, reasonok or declined; for declined, one sentence of at most 25 words, players empty and every other field empty.
verdictlevel, nudge, stagger or switch, by the check order above.
playersExactly player_count entries in input order. Each: n, tier (none, small, medium, large or beyond), handicap (at most 20 words, empty for none and beyond; with a target it names the amount - that percentage of the target, rounded to a whole number, never below 1) and why (the player's and the leader's wins, at most 22 words).
house_ruleOne catch-up or turn-order rule for the table, at most 28 words.
switch_ideaOnly for switch: another way to play the same game, at most 28 words; otherwise empty.
closingOne line of at most 18 words that agrees with the verdict.

The page then checks the reply (StartKit.check) against its own size for each player, the verdict, the count, every amount and the word limits, and lists any disagreement under "The page's check".

Worked examples

These are the five free samples on the page: the exact input and a stand-in model's reply, unedited. Together they cover every outcome - darts (nudge), chess (stagger), table tennis (switch), a word-tile game (level) and pool with money on it (declined).

Darts, three players - nudge

Input:

{
 "task": "level",
 "game": "Darts",
 "win_rule": "first to take 501 down to exactly zero, finishing on a double",
 "target": "501",
 "games_played": "10",
 "player_count": "3",
 "players": "1. Priya | wins: 6 | note: none\n2. Tom | wins: 3 | note: throws left-handed\n3. Ana | wins: 1 | note: new to finishing on a double"
}

Reply:

{
 "task": "level",
 "status": "ok",
 "reason": "",
 "verdict": "nudge",
 "players": [
  {
   "n": 1,
   "tier": "none",
   "handicap": "",
   "why": "Priya holds the lead with 6 wins and has no head start."
  },
  {
   "n": 2,
   "tier": "small",
   "handicap": "Give Tom a 50 head start, so Tom needs only 451 to finish.",
   "why": "Tom has 3 wins against Priya's 6 wins and gets a small head start."
  },
  {
   "n": 3,
   "tier": "medium",
   "handicap": "Give Ana a 100 head start, so Ana needs only 401 to finish.",
   "why": "Ana has 1 win against Priya's 6 wins and gets a medium head start."
  }
 ],
 "house_rule": "Let Ana throw first in each game, since Ana has the fewest wins.",
 "switch_idea": "",
 "closing": "This table needs small head starts for two players, not a bigger fix."
}

Chess, two players - stagger

Input:

{
 "task": "level",
 "game": "Chess",
 "win_rule": "checkmate the other king",
 "target": "not given",
 "games_played": "10",
 "player_count": "2",
 "players": "1. Sam | wins: 9 | note: none\n2. Lee | wins: 1 | note: prefers quick games"
}

Reply:

{
 "task": "level",
 "status": "ok",
 "reason": "",
 "verdict": "stagger",
 "players": [
  {
   "n": 1,
   "tier": "none",
   "handicap": "",
   "why": "Sam holds the lead with 9 wins and has no head start."
  },
  {
   "n": 2,
   "tier": "large",
   "handicap": "Play this game with Sam's queen removed, to help Lee.",
   "why": "Lee has 1 win against Sam's 9 wins and gets a large head start."
  }
 ],
 "house_rule": "Let Lee choose which color to play in this game.",
 "switch_idea": "",
 "closing": "This matchup needs a large head start, not a small adjustment."
}

Table tennis, four players - switch

Input:

{
 "task": "level",
 "game": "Table tennis",
 "win_rule": "first to 11 points, two clear",
 "target": "11",
 "games_played": "12",
 "player_count": "4",
 "players": "1. Maya | wins: 11 | note: none\n2. Jon | wins: 1 | note: none\n3. Kit | wins: 0 | note: only just learned to serve\n4. Player 4 | wins: 0 | note: none"
}

Reply:

{
 "task": "level",
 "status": "ok",
 "reason": "",
 "verdict": "switch",
 "players": [
  {
   "n": 1,
   "tier": "none",
   "handicap": "",
   "why": "Maya holds the lead with 11 wins and has no head start."
  },
  {
   "n": 2,
   "tier": "large",
   "handicap": "Start Jon's score at 3 points before this game begins.",
   "why": "Jon has 1 win against Maya's 11 wins and gets a large head start."
  },
  {
   "n": 3,
   "tier": "beyond",
   "handicap": "",
   "why": "Kit has 0 wins against Maya's 11 wins, too wide for a head start."
  },
  {
   "n": 4,
   "tier": "beyond",
   "handicap": "",
   "why": "Player 4 has 0 wins against Maya's 11 wins, too wide for a head start."
  }
 ],
 "house_rule": "Give the player with the fewest wins the serve first each game.",
 "switch_idea": "Play doubles, pairing Kit and Player 4 against Maya and Jon for a real contest.",
 "closing": "Two players' gaps are too wide for a head start; this table needs a different format."
}

A word-tile game - level

Input:

{
 "task": "level",
 "game": "Word-tile board game",
 "win_rule": "highest total score when the tiles run out",
 "target": "not given",
 "games_played": "8",
 "player_count": "3",
 "players": "1. Rosa | wins: 3 | note: none\n2. Ben | wins: 3 | note: none\n3. Ivy | wins: 2 | note: likes long words"
}

Reply:

{
 "task": "level",
 "status": "ok",
 "reason": "",
 "verdict": "level",
 "players": [
  {
   "n": 1,
   "tier": "none",
   "handicap": "",
   "why": "Rosa shares the lead with 3 wins and has no head start."
  },
  {
   "n": 2,
   "tier": "none",
   "handicap": "",
   "why": "Ben shares the lead with 3 wins and has no head start."
  },
  {
   "n": 3,
   "tier": "none",
   "handicap": "",
   "why": "Ivy has 2 wins while the lead sits at 3 wins, and has no head start."
  }
 ],
 "house_rule": "Let Ivy draw tiles first each game, since Ivy has the fewest wins.",
 "switch_idea": "",
 "closing": "No player here needs a head start right now."
}

Pool with money on it - declined

Input:

{
 "task": "level",
 "game": "Pool",
 "win_rule": "pot your seven balls, then the black",
 "target": "not given",
 "games_played": "6",
 "player_count": "2",
 "players": "1. Dev | wins: 4 | note: none\n2. Max | wins: 1 | note: loser pays £5 a frame"
}

Reply:

{
 "task": "level",
 "status": "declined",
 "reason": "This page only levels games played for fun and does not size bets.",
 "verdict": "",
 "players": [],
 "house_rule": "",
 "switch_idea": "",
 "closing": ""
}

Build the input yourself

Load the engine in node (global.window = global; require("./staggered.js"); require("./track.js")), then:

const K = StartKit, T = StartTrack;
const built = K.buildInput({ game: "Darts", win_rule: "first to take 501 down to zero", target: "501", games: "10",
  players: [ { name: "Priya", wins: "6", note: "" }, { name: "Tom", wins: "3", note: "" } ] });
built.parsed.rows.map(r => r.tier);      // the page's own sizes: ["none", "small"]
built.parsed.rows.map(r => r.amount);    // [0, 50] - 10% of 501, rounded
built.prescan.verdict;                   // "nudge"
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
// T.svg({ rows: built.parsed.rows })    // the track drawing
// K.md(built, reply)                    // the levelled game as Markdown

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://staggered-start.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":"staggered-start"}' | 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": "level", "game": "...", "player_count": "2", "players": "1. ...
2. ..."} - see the field table
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 staggered-start:<task>:<inputHash>:a<attempt> (the one task is level), 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="staggered-start:level:$(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: staggered-start:level:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json