← Turn the Crank
API

Drive Turn the Crank from your own code

The one metered task, make-a-game, is available over HTTP. It takes one dull job, the parts it is made of (one per line, with an optional note such as "the worst bit" or "quick"), the person's small treats and, optionally, the minutes a usual go takes. It returns a score of 1 to 3 for every part with a cheer for each, four capsules (one prize per treat, short pauses for the rest), an opening line and a kind stopping line.

The division of labour first: the page plays; the model makes the game. The page numbers the parts and treats, reads each part's note for its own score, works out how many capsules of each kind to ask for, and sends those as facts. After the reply it checks the scores against the notes, the treats against the capsules and the words against the input, and runs the gumball machine - balls, rows, the crank and the clock - entirely in the browser.

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":"turn-the-crank"}), 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, job, parts, treats, minutes, facts) 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 reasons are metered and need a personal sign-in.
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 input

Every run sends task: make-a-game (the one task is make-a-game); a missing or different one is still answered as make-a-game. All fields are strings.

FieldRequiredWhat it holds
taskyesAlways make-a-game.
jobyesThe dull job, up to 80 characters.
partsyes2 to 8 parts, numbered, one per line, each up to 80 characters, exactly as typed (a note may follow a dash).
treatsyesUp to 4 small treats, numbered, one per line, or none.
minutesyesMinutes a usual go takes (1 to 600), or not given.
factsyesThe page's lines: how many parts and treats were sent (and how many were left out), and the capsule plan - 4 capsules, treats first, pauses for the rest.
retry_notenoOnly on a reformat retry: why the previous reply was unusable.

Scores: a part whose own line calls it the worst, slowest, hardest, fiddly or one the person hates is worth 3; one called quick, easy, fine or liked is worth 1; every other part is worth 2. Each point drops one ball; every 10 points fill a row and earn one capsule.

The reply

One JSON object, no prose around it:

{"task": "make-a-game", "pitch": "...", "moves": [{"part": "...", "points": 2, "cheer": "..."}], "capsules": [{"kind": "treat", "text": "..."}], "stop": "..."}
FieldWhat it holds
moves[]One per part, same order, part copied exactly; points 1, 2 or 3; cheer one sentence of 4 to 12 words shown when the part is tapped.
capsules[]Exactly 4: one treat per listed treat, in order, then pause capsules; each one sentence of at most 16 words.
pitchTwo sentences, 20 to 45 words: how the game works for this job.
stopOne sentence, at most 30 words: stop when the job is done (or the minutes are up); stopping early costs nothing.

The page then checks the reply (CrankKit.check) - a score for every part that matches its note, a capsule for every treat, the right count of each capsule kind, and no time word, number, rushing, pronoun or contact detail you did not give - and lists what slipped under "The page's check". Any missing score or capsule is filled from the house game.

Worked examples - every score and both capsule kinds

These are the 3 free samples on the page: the exact input the page sends and the reply that replays for free. Together they cover scores of 1, 2 and 3, treat and pause capsules, a job with no treats and one with no minutes.

Packing boxes - scores 2, 3, 2, 1, 2 treat capsules

Input:

{
 "task": "make-a-game",
 "job": "Packing the spare room into boxes",
 "parts": "1. Tape up a flat box\n2. Wrap a mug in newspaper - the worst bit, so fiddly\n3. Fill a box with books\n4. Write the room on the box - quick",
 "treats": "1. a biscuit\n2. an episode of my podcast",
 "minutes": "90",
 "facts": "Parts sent: 4.\nTreats sent: 2.\nCapsules to write: 4 - 2 treat, then 2 pause."
}

Reply:

{
 "task": "make-a-game",
 "pitch": "Each time you finish a part while packing the spare room into boxes, you tap it and watch balls drop into the glass. Parts with more points send down more balls, and a full row lets you turn the crank for a capsule.",
 "moves": [
  {
   "part": "Tape up a flat box",
   "points": 2,
   "cheer": "That flat box is taped up now!"
  },
  {
   "part": "Wrap a mug in newspaper - the worst bit, so fiddly",
   "points": 3,
   "cheer": "You just wrapped that mug in newspaper!"
  },
  {
   "part": "Fill a box with books",
   "points": 2,
   "cheer": "Another box is filled with books."
  },
  {
   "part": "Write the room on the box - quick",
   "points": 1,
   "cheer": "The room is now written on the box."
  }
 ],
 "capsules": [
  {
   "kind": "treat",
   "text": "A biscuit is waiting for you inside this capsule."
  },
  {
   "kind": "treat",
   "text": "Turn the crank, and this capsule hands you an episode of your podcast."
  },
  {
   "kind": "pause",
   "text": "This capsule offers you a short break before you carry on."
  },
  {
   "kind": "pause",
   "text": "Nothing is required now except a little time to rest."
  }
 ],
 "stop": "Stop when the spare room is packed into boxes or when 90 minutes have passed, whichever happens sooner, and stopping before then costs you nothing."
}

Scanning letters - scores 2, 3, 2, 2, 0 treat capsules

Input:

{
 "task": "make-a-game",
 "job": "Scanning a shoebox of old letters",
 "parts": "1. Unfold a letter\n2. Scan both sides - the slowest part\n3. Name the file\n4. Put the letter on the done pile",
 "treats": "none",
 "minutes": "not given",
 "facts": "Parts sent: 4.\nTreats sent: 0.\nCapsules to write: 4 - 0 treat, then 4 pause."
}

Reply:

{
 "task": "make-a-game",
 "pitch": "Once you finish a part of scanning a shoebox of old letters, you tap it, and parts carrying more points drop more balls. Every full row of balls earns a capsule for you to open with the crank.",
 "moves": [
  {
   "part": "Unfold a letter",
   "points": 2,
   "cheer": "You've unfolded a letter!"
  },
  {
   "part": "Scan both sides - the slowest part",
   "points": 3,
   "cheer": "Both sides are scanned!"
  },
  {
   "part": "Name the file",
   "points": 2,
   "cheer": "You've named the file!"
  },
  {
   "part": "Put the letter on the done pile",
   "points": 2,
   "cheer": "You've put the letter on the done pile!"
  }
 ],
 "capsules": [
  {
   "kind": "pause",
   "text": "Take a short rest before you continue."
  },
  {
   "kind": "pause",
   "text": "Pause for a moment, if you would like to."
  },
  {
   "kind": "pause",
   "text": "Stop and rest for a little while."
  },
  {
   "kind": "pause",
   "text": "Give yourself a brief pause, with no need to rush."
  }
 ],
 "stop": "Stop once scanning a shoebox of old letters is done, or whenever you want to, since stopping early costs nothing."
}

Cleaning the car - scores 2, 3, 1, 2, 2, 4 treat capsules

Input:

{
 "task": "make-a-game",
 "job": "Cleaning out the car",
 "parts": "1. Empty the door pockets\n2. Vacuum the footwells - I hate this\n3. Wipe the dashboard - easy\n4. Shake out the mats\n5. Clear the boot",
 "treats": "1. a coffee from the van on the corner\n2. singing along to the radio\n3. a sit in the sun\n4. a chocolate bar",
 "minutes": "40",
 "facts": "Parts sent: 5.\nTreats sent: 4 (the person listed 5; the rest were left out).\nCapsules to write: 4 - 4 treat, then 0 pause."
}

Reply:

{
 "task": "make-a-game",
 "pitch": "Each time you finish a part of cleaning out the car, give it a tap and watch balls land in the glass, more of them for parts worth more points. Fill up a row and the crank pops a capsule open for you.",
 "moves": [
  {
   "part": "Empty the door pockets",
   "points": 2,
   "cheer": "Wonderful, you emptied the door pockets!"
  },
  {
   "part": "Vacuum the footwells - I hate this",
   "points": 3,
   "cheer": "Well done, the footwells are vacuumed!"
  },
  {
   "part": "Wipe the dashboard - easy",
   "points": 1,
   "cheer": "Nice work, you wiped the dashboard!"
  },
  {
   "part": "Shake out the mats",
   "points": 2,
   "cheer": "Lovely job, you shook out the mats!"
  },
  {
   "part": "Clear the boot",
   "points": 2,
   "cheer": "Fantastic, the boot is cleared!"
  }
 ],
 "capsules": [
  {
   "kind": "treat",
   "text": "This capsule holds a coffee from the van on the corner."
  },
  {
   "kind": "treat",
   "text": "Turning the crank here brings out singing along to the radio."
  },
  {
   "kind": "treat",
   "text": "Inside this capsule waits a sit in the sun."
  },
  {
   "kind": "treat",
   "text": "A chocolate bar is the prize inside this capsule."
  }
 ],
 "stop": "Stop when cleaning out the car is finished or when 40 minutes have passed, whichever happens sooner, and stopping before then costs nothing."
}

Build the input yourself

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

const K = CrankKit;
const form = K.form({ job: "Scanning a shoebox of old letters", parts: "Unfold a letter\nScan both sides - the slowest part", treats: "", minutes: "" });
const built = K.buildInput(form);       // built.facts holds the parts, the treats and the page's own scores
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.gameOf(built.facts, reply)         // the playable game (house values fill any gap)

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://turn-the-crank.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":"turn-the-crank"}' | 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": "make-a-game", "job": "Scanning a shoebox of old letters", "parts": "1. Unfold a letter\n2. Scan both sides - the slowest part\n...", "treats": "none", "minutes": "not given", "facts": "...", ...}
# - 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 turn-the-crank:<task>:<inputHash>:a<attempt> (the one task is make-a-game), 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="turn-the-crank:make-a-game:$(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: turn-the-crank:make-a-game:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json