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:
| 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 is answered as critique, with "task":"critique" in the reply.
| task | What you get | Fields it leans on |
|---|---|---|
critique | A 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. |
sheet | A 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.
| Field | What it holds |
|---|---|
task | "critique" or "sheet". |
ruleset | JSON 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. |
summary | Empty 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. |
lint | JSON string: an array of problems already found in the ruleset, each "err: ...", "warn: ..." or "note: ..."; "[]" when clean. |
fields | JSON 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. |
previous | Empty 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_note | Free text: audience, feel, length. May be empty; over 600 characters it is cut at a sentence boundary with a marker. |
clipped | What was cut before sending, or "nothing clipped; move logs and game trees are never sent". |
retry_note | Optional. 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:
- Every finding's
statmust be one of the statistics the simulation reports (p1_win_rate,p2_win_rate,draw_rate,first_player_edge,p1_share_of_decisive,avg_plies,median_plies,avg_branching,decider_share.*,captures_per_game.*), and itsvaluemust match the summary within 0.03 on a rate, share or edge, 5% (at least 1) on a ply count and 0.5 on a per-game capture count. A percentage written in prose must sit within 3 points of some rate in the summary. - Every change's
fieldmust be a path copied fromfields, itsvaluemust be legal for that field's type and range (and differ from the current value), and a second change to the same field is rejected. Accepted changes are applied to the ruleset and the game is re-simulated; the next critique sends the earlier summary and the accepted changes asprevious. - The verdict is checked against the numbers:
lopsidedneeds |first_player_edge| ≥ 0.08,drawisha draw_rate ≥ 0.3, andpromisingis flagged when the edge is ≥ 0.2 or the draw rate ≥ 0.5. - A rules card must name exactly the ruleset's pieces and its switched-on win conditions, state the connect n, the board's W x H, the reserve when there is a placement phase, and name the games count in
stats_line.
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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://plyquarry.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"]
// plyquarry.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://plyquarry.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://plyquarry.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
}
// Plyquarry.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 Plyquarry {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://plyquarry.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://plyquarry.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://plyquarry.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://plyquarry.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":"plyquarry"}' | 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": "plyquarry"})["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: "plyquarry" })).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": "plyquarry"}, 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", "plyquarry"), 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: "plyquarry" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "plyquarry"])["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 = "plyquarry" }))!["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: 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}'
inp = json.load(open("input.json")) # {"task": "critique", "ruleset": "{...}", "summary": "{...}", "lint": "[...]", "fields": "[...]", "previous": "", "designer_note": "...", "clipped": "..."}
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, ruleset, summary, lint, fields, previous, designer_note, clipped}
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 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}'
import hashlib, time
key = "plyquarry:" + 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("verdict"))
import { createHash } from "node:crypto";
const key = `plyquarry:${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.verdict);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "plyquarry:" + 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 = "plyquarry:" + 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 = "plyquarry:#{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["verdict"]].inspect
$key = "plyquarry:" . $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["verdict"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"plyquarry:{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: plyquarry:critique:$(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": "critique", "ruleset": "{...}", "summary": "{...}", "lint": "[...]", "fields": "[...]", "previous": "", "designer_note": "...", "clipped": "..."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "plyquarry:critique:" + 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": `plyquarry:critique:${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", "plyquarry:critique:"+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", "plyquarry:critique:" + 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" => "plyquarry:critique:#{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: plyquarry:critique:" . 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", $"plyquarry:critique:{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);