Drive Who Spun It from your own code
The one metered task, spin-check, is available over HTTP. It takes a paper spinner on a needle (the kind often shown as a telekinesis or mind-power wheel), what someone thinks turns it, up to 6 logged rounds (cup on or off, hands away / near / close, breath normal or held, room air still or moving, and how much it turned) and the page's facts, and returns the verdict, a note for every round, why that push can turn light paper, why the next round is worth trying, a headline and a closing line.
The division of labour first: the page pairs the rounds, a fixed rule decides the verdict and the page picks the next single change; the model explains them. After a run the page checks that the verdict follows the rule, that every counted round has its note in order, that the next step names the page's starting round, that every number comes from the rounds or notes, and that no ids leak into the prose.
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": "..." } }
The token is minted for this app (the guest call takes {"slug":"who-spun-it"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field (task, setup, idea, rounds, facts) is missing. |
| 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: a reading is metered and needs a personal sign-in. |
| 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, the input and the verdicts
Every run sends task: spin-check (the one task is spin-check); a missing or different one is still answered as spin-check. A pair is 2 counted rounds that differ in exactly 1 condition; it is decisive when the spin differs, 1 round reached a slow turn or more and the round with more push (cup off, hands closer, normal breathing, moving air) turned more, odd when more push gave less spin, faint when neither got past a wobble, and flat when the spin was the same. The verdict rule, checked in order: nothing past a wobble, no-spin; a slow turn or more with the cup off and hands away, room-air; no decisive pair, need-a-pair; otherwise the decisive pair that changed hands, then breath, then air, then the cup decides: warm-hands, your-breath, room-air or moving-air. The next change always alters 1 condition from 1 logged round (or repeats a round, or asks to check the spinner).
| Field | Required | What it holds |
|---|---|---|
task | yes | Always spin-check. |
setup | yes | The spinner and what it sits on, up to 140 characters. |
idea | yes | What someone thinks turns it, up to 120 characters, or none. |
rounds | yes | 1 line per counted round: round 1 | cup: off (...) | hands: close (...) | breath: normal | air: still (...) | spin: steady spin | note: .... |
facts | yes | The page's lines: verdict:, deciding pair:, spin with cup off and hands away:, repeats:, other pairs:, suspects: (warm hands, your breath, room air) and next change:. |
retry_note | no | Only on the page's single reformat retry. |
The reply is one JSON object:
{"task": "spin-check", "verdict": "warm-hands", "headline": "...", "reads": [{"round": 1, "read": "..."}, ...], "physics": "...", "next_why": "...", "closing": "..."}
| Field | What it holds |
|---|---|
verdict | no-spin, room-air, need-a-pair, warm-hands, your-breath or moving-air, copied from the facts. |
headline | The verdict in plain words, at most 16 words. |
reads | 1 entry per counted round, in order: round (its number) and read, at most 25 words. |
physics | Why that push can turn light paper on a needle point, at most 45 words. |
next_why | The page's next change, naming its round, and what each result would mean, at most 30 words. |
closing | 1 light sentence that fits the verdict, at most 20 words. |
The page then checks the reply (SpinKit.check) and lists any disagreement under "The page's check".
Worked examples - every verdict
These are the 6 free samples on the page: the exact input the page sends and the reply that replays for free.
A foil spinner that only wobbles - no-spin
Input:
{
"task": "spin-check",
"setup": "foil square folded corner to corner, balanced on a pin pushed into a wine cork",
"idea": "a video said staring at it makes it turn",
"rounds": "round 1 | cup: off (spinner open to the room) | hands: away (no hand within 50 cm) | breath: normal | air: still (fans, heaters and vents off, doors and windows shut) | spin: none | note: none\nround 2 | cup: off (spinner open to the room) | hands: near (5 to 50 cm) | breath: normal | air: still (fans, heaters and vents off, doors and windows shut) | spin: wobble | note: tiny wobble when I leaned in\nround 3 | cup: off (spinner open to the room) | hands: close (under 5 cm, not touching) | breath: normal | air: still (fans, heaters and vents off, doors and windows shut) | spin: wobble | note: none",
"facts": "verdict: no-spin\ndeciding pair: none\nspin with cup off and hands away: none\nrepeats: none\nother pairs: round 1 vs round 2 | changed: hands away -> near | spin: none -> wobble | faint ; round 1 vs round 3 | changed: hands away -> close | spin: none -> wobble | faint ; round 2 vs round 3 | changed: hands near -> close | spin: wobble -> wobble | flat\nsuspects: warm hands = made no clear difference | your breath = not tested yet | room air = not tested yet\nnext change: repeat round 3 with the air moving and nothing else changed, to see whether a stronger push turns it at all"
}
Reply:
{
"task": "spin-check",
"verdict": "no-spin",
"headline": "No round beat a wobble yet, so there's no clear push to explain.",
"reads": [
{
"round": 1,
"read": "Hands away, air still: nothing moved. This is the baseline that rounds 2 and 3 are compared against."
},
{
"round": 2,
"read": "Hands near gave only a tiny wobble when you leaned in - barely more than hands away, and the same as closer in round 3."
},
{
"round": 3,
"read": "Hands brought close still only wobbled, no better than hands near in round 2, though still more than hands away in round 1."
}
],
"physics": "None of these rounds sent a current strong enough to catch the folds and turn the foil - the needle point is nearly frictionless, so even a very light push can tip it, but nothing here was quite enough.",
"next_why": "Round 3 repeats with the air moving, to see if a stronger push can turn it - turning points to air, no change means air isn't enough either.",
"closing": "Worth a look with more air moving - who knows what the next round might show."
}
It turned before anyone sat down - room-air
Input:
{
"task": "spin-check",
"setup": "printer paper square creased into a tent, on a sewing needle in a pencil eraser",
"idea": "my flatmate reckons it reacts to whoever is nearest",
"rounds": "round 1 | cup: off (spinner open to the room) | hands: away (no hand within 50 cm) | breath: normal | air: still (fans, heaters and vents off, doors and windows shut) | spin: slow turn | note: started turning before I sat down\nround 2 | cup: off (spinner open to the room) | hands: close (under 5 cm, not touching) | breath: normal | air: still (fans, heaters and vents off, doors and windows shut) | spin: steady spin | note: none",
"facts": "verdict: room-air\ndeciding pair: none\nspin with cup off and hands away: round 1 | spin: slow turn\nrepeats: none\nother pairs: round 1 vs round 2 | changed: hands away -> close | spin: slow turn -> steady spin | decisive\nsuspects: warm hands = points here | your breath = not tested yet | room air = points here\nnext change: repeat round 1 with the cup on and nothing else changed, to see whether shutting out the air around it stops the spin"
}
Reply:
{
"task": "spin-check",
"verdict": "room-air",
"headline": "Even with hands well away, the room's air alone turned it this time.",
"reads": [
{
"round": 1,
"read": "Hands away and cup off still gave a slow turn before you sat down; round 2's closer hands spun it faster."
},
{
"round": 2,
"read": "Hands brought close turned round 1's slow turn into a steady spin - the clearest jump, pointing to warmth or moving air."
}
],
"physics": "A spinner on a needle point has almost no friction, so even a small stray draught - from a vent, heater, open door or someone passing by - can catch the tent's slanted folds and spin it, hand or no hand nearby.",
"next_why": "Round 1 repeats with the cup on, to see if blocking outside air stops the spin - stopping points to the room's air, continuing means something else is at play.",
"closing": "Let's see if shutting the air out with the cup keeps the spin going or stops it."
}
Three things changed at once - need-a-pair
Input:
{
"task": "spin-check",
"setup": "sticky note folded into a little pyramid on a needle stuck in a blob of putty",
"idea": "focusing hard on it",
"rounds": "round 1 | cup: on (a clear cup or jar over the spinner) | hands: away (no hand within 50 cm) | breath: held or face turned away | air: still (fans, heaters and vents off, doors and windows shut) | spin: none | note: none\nround 2 | cup: off (spinner open to the room) | hands: close (under 5 cm, not touching) | breath: normal | air: still (fans, heaters and vents off, doors and windows shut) | spin: steady spin | note: I was concentrating really hard\nround 3 | cup: off (spinner open to the room) | hands: near (5 to 50 cm) | breath: held or face turned away | air: moving (a fan, heater, vent, open door or window) | spin: slow turn | note: the radiator had come on",
"facts": "verdict: need-a-pair\ndeciding pair: none\nspin with cup off and hands away: none\nrepeats: none\nother pairs: none\nsuspects: warm hands = not tested yet | your breath = not tested yet | room air = not tested yet\nnext change: repeat round 3 with the cup on and nothing else changed, so the next round differs from round 3 in only 1 condition"
}
Reply:
{
"task": "spin-check",
"verdict": "need-a-pair",
"headline": "These rounds each changed more than 1 thing, so no single cause stands out yet.",
"reads": [
{
"round": 1,
"read": "With the cup on, hands away and your breath held, nothing moved - this round has no partner that changes only 1 condition yet."
},
{
"round": 2,
"read": "Cup off, hands close, breath normal gave a steady spin, but more than 1 condition differs from round 1, so no pair yet."
},
{
"round": 3,
"read": "Air moving after the radiator came on gave a slow turn, but more than 1 condition differs from the others, so no pair yet."
}
],
"physics": "A pyramid this light turns on almost no push, since the needle point barely resists it. But here every round changed more than 1 thing at once, so there's no way yet to tell which change - hands, breath or air - did it.",
"next_why": "Round 3 repeats with the cup now on, hands and breath unchanged, to test the air - stopping points to air, still turning points elsewhere.",
"closing": "Time for a round that changes just 1 thing, so a clearer pattern might show up."
}
Hands closer, more spin - warm-hands
Input:
{
"task": "spin-check",
"setup": "square of tracing paper folded twice, balanced on a needle in a cork on the kitchen table",
"idea": "my nephew says it is mind power",
"rounds": "round 1 | cup: off (spinner open to the room) | hands: near (5 to 50 cm) | breath: held or face turned away | air: still (fans, heaters and vents off, doors and windows shut) | spin: wobble | note: hands about a ruler away\nround 2 | cup: off (spinner open to the room) | hands: close (under 5 cm, not touching) | breath: held or face turned away | air: still (fans, heaters and vents off, doors and windows shut) | spin: steady spin | note: took a few seconds to get going\nround 3 | cup: off (spinner open to the room) | hands: close (under 5 cm, not touching) | breath: normal | air: still (fans, heaters and vents off, doors and windows shut) | spin: steady spin | note: none",
"facts": "verdict: warm-hands\ndeciding pair: round 1 vs round 2 | changed: hands near -> close | spin: wobble -> steady spin | decisive\nspin with cup off and hands away: none\nrepeats: none\nother pairs: round 2 vs round 3 | changed: breath held -> normal | spin: steady spin -> steady spin | flat\nsuspects: warm hands = points here | your breath = made no clear difference | room air = not tested yet\nnext change: repeat round 2 with the cup on and nothing else changed, to see whether shutting out the air around it stops the spin"
}
Reply:
{
"task": "spin-check",
"verdict": "warm-hands",
"headline": "Moving your hands closer clearly sped it up, pointing to warmth from your hands.",
"reads": [
{
"round": 1,
"read": "With hands near - about a ruler away - and your breath held, it only wobbled; round 2 shows what closer hands then did."
},
{
"round": 2,
"read": "Hands brought close turned the wobble in round 1 into a steady spin, taking a few seconds to start - the clearest jump here."
},
{
"round": 3,
"read": "Breathing normally instead of holding it, with hands still close, gave the same steady spin as round 2 - breath made no clear difference."
}
],
"physics": "Tracing paper this light needs almost no push to move, since the needle point has so little friction. Air warmed by your hands near the folds rises and drifts past them, enough on its own to spin it steadily.",
"next_why": "Round 2 repeats with the cup on, to see if shutting out the air stops the spin - stopping fits warm hands, still turning points elsewhere.",
"closing": "Let's see if the cup keeps that warmth-driven spin going or brings it to a stop."
}
Leaning right over it - your-breath
Input:
{
"task": "spin-check",
"setup": "paper tent cut from a receipt, on a pushpin standing in a cork",
"idea": "my sister says I am doing it with my mind",
"rounds": "round 1 | cup: off (spinner open to the room) | hands: near (5 to 50 cm) | breath: normal | air: still (fans, heaters and vents off, doors and windows shut) | spin: slow turn | note: I was leaning right over it\nround 2 | cup: off (spinner open to the room) | hands: near (5 to 50 cm) | breath: held or face turned away | air: still (fans, heaters and vents off, doors and windows shut) | spin: none | note: turned my face to the side",
"facts": "verdict: your-breath\ndeciding pair: round 1 vs round 2 | changed: breath normal -> held | spin: slow turn -> none | decisive\nspin with cup off and hands away: none\nrepeats: none\nother pairs: none\nsuspects: warm hands = not tested yet | your breath = points here | room air = not tested yet\nnext change: repeat round 1 with your hands away and nothing else changed, to take your hands out of the picture"
}
Reply:
{
"task": "spin-check",
"verdict": "your-breath",
"headline": "Holding your breath stopped the spin, so your breath looks like the main push.",
"reads": [
{
"round": 1,
"read": "Hands near and breathing normally as you leaned right over it gave a slow turn - holding your breath in round 2 stopped it."
},
{
"round": 2,
"read": "Hands still near, but holding your breath and turning your face to the side stopped it completely - the clearest change in this pair."
}
],
"physics": "A receipt-paper tent this light spins with barely any push, since the needle point offers almost no resistance. Breath is a warm, moving current, and leaning over it sends that current straight at the folds; holding your breath removes it.",
"next_why": "Round 1 repeats with your hands now away, to rule hands out of the picture - keeps turning still points to your breath, stopping would bring warm hands back in.",
"closing": "Let's see if it still turns with your hands well out of the way this time."
}
Under the cup it stops - moving-air
Input:
{
"task": "spin-check",
"setup": "thin card square folded into 4 vanes, on a needle in an eraser",
"idea": "none",
"rounds": "round 1 | cup: off (spinner open to the room) | hands: close (under 5 cm, not touching) | breath: normal | air: still (fans, heaters and vents off, doors and windows shut) | spin: steady spin | note: none\nround 2 | cup: on (a clear cup or jar over the spinner) | hands: close (under 5 cm, not touching) | breath: normal | air: still (fans, heaters and vents off, doors and windows shut) | spin: none | note: no movement at all for a minute",
"facts": "verdict: moving-air\ndeciding pair: round 1 vs round 2 | changed: cup off -> on | spin: steady spin -> none | decisive\nspin with cup off and hands away: none\nrepeats: none\nother pairs: none\nsuspects: warm hands = not tested yet | your breath = not tested yet | room air = not tested yet\nnext change: repeat round 1 with your breath held and nothing else changed, to take your breath out of the picture"
}
Reply:
{
"task": "spin-check",
"verdict": "moving-air",
"headline": "Covering it with the cup stopped the spin, pointing to outside air so far.",
"reads": [
{
"round": 1,
"read": "With the cup off and hands close, it spun steadily - putting the cup on in round 2 stopped it completely."
},
{
"round": 2,
"read": "Putting the cup on with hands still close killed the spin completely for a minute - the clearest change yet, pointing outside the cup."
}
],
"physics": "A card this light needs only the faintest push to spin, since the needle point has hardly any friction. The cup seals out the room's air, and with it on the vanes had nothing to catch, so the push came from outside the cup.",
"next_why": "Round 1 repeats with your breath held this time, to take breath out of the picture - still spinning backs up outside air, stopping would bring breath back into play.",
"closing": "Let's see if it still spins once your breath is held and out of the way."
}
Build the input yourself
Load the engine in node (global.window = global; require("./spinkit.js")), then:
const K = SpinKit;
const built = K.buildInput(K.form({ setup: "paper square folded into a tent on a needle in a cork", idea: "none",
trials: [{ jar: "off", hands: "near", breath: "normal", air: "still", spin: 1 },
{ jar: "off", hands: "close", breath: "normal", air: "still", spin: 3 }] }));
built.input.facts; // the page's lines: verdict, pairs, suspects, next change
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// built.facts.verdict // the verdict the page's rule gives
// K.check(reply, built).notes // the page's re-check of the reply
// K.md(built, reply, "my session") // the session 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://who-spun-it.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://who-spun-it.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"]
// who-spun-it.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://who-spun-it.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://who-spun-it.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
}
// WhatWasThat.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 WhatWasThat {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://who-spun-it.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://who-spun-it.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://who-spun-it.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://who-spun-it.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":"who-spun-it"}' | 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": "who-spun-it"})["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: "who-spun-it" })).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": "who-spun-it"}, 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", "who-spun-it"), 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: "who-spun-it" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "who-spun-it"])["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 = "who-spun-it" }))!["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, 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": "spin-check", "setup": "thin card square folded into 4 vanes, on a needle in an eraser", "idea": "none", "rounds": "round 1 | cup: off (...) | hands: close (...) | breath: normal | air: still (...) | spin: steady spin | note: none\nround 2 | cup: on (...) | ...", "facts": "verdict: moving-air\ndeciding pair: round 1 vs round 2 | ...\nnext change: ..."}
# - see the field table
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": "spin-check", "setup": "thin card square folded into 4 vanes, on a needle in an eraser", "idea": "none", "rounds": "round 1 | cup: off (...) | hands: close (...) | breath: normal | air: still (...) | spin: steady spin | note: none\nround 2 | cup: on (...) | ...", "facts": "verdict: moving-air\ndeciding pair: round 1 vs round 2 | ...\nnext change: ..."}
# - see the field table
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: "spin-check", setup, idea, rounds, facts} (see the field table)
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 who-spun-it:<task>:<inputHash>:a<attempt> (the one task is spin-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="who-spun-it:spin-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}'
import hashlib, time
key = "who-spun-it:" + 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 reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `who-spun-it:${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 := "who-spun-it:" + 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 = "who-spun-it:" + 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 = "who-spun-it:#{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 = "who-spun-it:" . $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 = $"who-spun-it:{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: who-spun-it:spin-check:$(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": "spin-check", "setup": "thin card square folded into 4 vanes, on a needle in an eraser", "idea": "none", "rounds": "round 1 | cup: off (...) | hands: close (...) | breath: normal | air: still (...) | spin: steady spin | note: none\nround 2 | cup: on (...) | ...", "facts": "verdict: moving-air\ndeciding pair: round 1 vs round 2 | ...\nnext change: ..."}
# - see the field table
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "who-spun-it:spin-check:" + 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": `who-spun-it:spin-check:${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", "who-spun-it:spin-check:"+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", "who-spun-it:spin-check:" + 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" => "who-spun-it:spin-check:#{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: who-spun-it:spin-check:" . 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", $"who-spun-it:spin-check:{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);