← Thock or Clack
API

Drive Thock or Clack from your own code

The one metered task, tune, is available over HTTP. It takes a keyboard's twelve parts (each an option id or unknown), the sound the person wants in their own words, what bugs them now, where they type, and the page's own notes. It returns a reading of how the board likely sounds now, up to five changes in the order to try them (mods, then a switch or keycap swap, then a rebuild), a fixed verdict, a room note and what to listen for after the first change.

The division of labour first: the page owns the parts and their options, the pitch and loudness rules of thumb, the word reading, the room limits, the drawing and the typing preview; the model owns the reading of the sound, the choice of changes and the wording; the page then re-checks the reply. The model never writes a pitch or loudness number.

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":"thock-or-clack"}), 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, board, wish, room, page_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: a tune-up is metered and needs a personal token.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field and the verdicts

Every run sends task: tune (the one task is tune); a missing or different one is still answered as tune. The verdict is a rule on the reply's own changes: keep with none; otherwise rebuild if any change is the case, plate or mount, else swap if any change is the switches, keycap plastic or keycap shape, else tune (only foam, tape, lube, stabilisers).

FieldRequiredWhat it holds
taskyesAlways tune.
boardyesA JSON-encoded object (a string) with layout, switches, keycap_material, keycap_profile, case, plate, mount, foam, tape, lube, stabs, hotswap - each an option id or unknown.
wishyesThe sound and feel wanted, in the person's words (up to 400 characters).
roomyesalone, shared, calls or sleeper.
page_factsyesThe page's notes, one key | ... per line: known_parts, estimate_now, wish_reading (a hint only), room_limit, tiers, allowed_to and verdict_rule.
bothernoWhat bugs the person now (up to 300 characters).
clip_notenoWhat the page cut, if anything.
retry_notenoOnly on the automatic reformat retry.

The reply

One JSON object, in output.output of the finished job:

{"task": "tune", "title": "...", "now_sound": "...",
 "changes": [{"part": "foam", "to": "case", "effect": "deeper", "why": "..."}],
 "verdict": "keep | tune | swap | rebuild", "verdict_reason": "...", "room_note": "...", "listen_for": "..."}
FieldWhat it holds
now_soundHow the board likely sounds and feels now, from its known parts (at most 35 words).
changes0 to 5 changes in the order to try them: part (one of the ten changeable parts, once each), to (a value from allowed_to, never the current one), effect (deeper, brighter, quieter, louder, cleaner, smoother, bouncier or clickier) and why (at most 25 words; desoldering and new-case notes where they apply).
verdict / verdict_reasonThe rule above, and one sentence (at most 30 words).
room_note / listen_for / titleWhether the sound suits the room (at most 30 words); what to listen for after the first change (at most 30 words); a playful title (at most 8 words).

The page then checks the reply (KeyKit.check): real parts and options, nothing set to what the board already has, the verdict against the rule on the reply's own changes, the direction of the changes against the wished words (by the page's rules of thumb), the room limit after the changes, desoldering and new-case notes, word limits, and no brand, price, scale number, weekday, month, season, he or she, furniture or internal field name in the text. Pitch and loudness come from KeyKit.estimate, never from the model.

Worked examples, one per verdict

These are the four free samples on the page: the exact input and a stand-in model's reply, unedited. Together they cover keep it, tune it, swap parts and rebuild - a board that already thocks, a hollow, rattly one, a clicky one on open-mic calls, and a hard-bottoming one whose owner wants a flexy feel.

A foamed gasket board that already thocks - keep it

Input:

{
 "task": "tune",
 "board": "{\"layout\":\"p65\",\"switches\":\"linear\",\"keycap_material\":\"pbt\",\"keycap_profile\":\"tall\",\"case\":\"aluminium\",\"plate\":\"polycarbonate\",\"mount\":\"gasket\",\"foam\":\"full\",\"tape\":\"tape\",\"lube\":\"lubed\",\"stabs\":\"tuned\",\"hotswap\":\"yes\"}",
 "wish": "A deep, round, muted thock. Honestly I love it - is there anything left to change?",
 "room": "shared",
 "page_facts": "known_parts | 12 of 12 parts given\nestimate_now | pitch 16 of 100 (0 = deep thock, 100 = bright clack), loudness 34 of 100 - the page's rules of thumb, rougher with unknown parts\nwish_reading | deeper, quieter (words: deep, muted) - the page's word match, a hint that can be wrong\nroom_limit | shared (a shared room or office): loudness 60 or under\ntiers | mod: foam, tape, lube, stabs; swap: switches, keycap_material, keycap_profile; rebuild: case, plate, mount\nallowed_to | switches: linear, tactile, clicky, silent-linear, silent-tactile; keycap_material: abs, pbt; keycap_profile: low, medium, tall, flat; case: plastic, aluminium, wood; plate: steel, aluminium, brass, polycarbonate, fr4, pom, plateless; mount: tray, top, gasket; foam: none, case, full; tape: none, tape; lube: stock, lubed; stabs: rattly, tuned\nverdict_rule | keep when there are no changes; otherwise the highest tier among the changes: rebuild if any change is case, plate or mount; else swap if any change is switches, keycap_material or keycap_profile; else tune (only foam, tape, lube, stabs)"
}

Reply:

{
 "task": "tune",
 "title": "Already Thocking Exactly The Way You Want",
 "now_sound": "Linear switches and full foam under the plate and case already give a deep, muted thock with little bright edge to it.",
 "changes": [],
 "verdict": "keep",
 "verdict_reason": "Nothing needs to change - the deep, muted thock you already have fits comfortably within what a shared room allows.",
 "room_note": "A deep, muted thock like this sits easily in a shared room - just keep an ear on how loud your typing gets if others are nearby.",
 "listen_for": "Listen for that deep, round thock holding steady across every key, staying muted even as you type faster."
}

A hollow, rattly plastic board - tune it

Input:

{
 "task": "tune",
 "board": "{\"layout\":\"tkl\",\"switches\":\"linear\",\"keycap_material\":\"pbt\",\"keycap_profile\":\"medium\",\"case\":\"plastic\",\"plate\":\"steel\",\"mount\":\"tray\",\"foam\":\"none\",\"tape\":\"none\",\"lube\":\"stock\",\"stabs\":\"rattly\",\"hotswap\":\"unknown\"}",
 "wish": "Smooth and a bit deeper, without the hollow ring.",
 "room": "shared",
 "page_facts": "known_parts | 11 of 12 parts given; unknown: hotswap\nestimate_now | pitch 61 of 100 (0 = deep thock, 100 = bright clack), loudness 55 of 100 - the page's rules of thumb, rougher with unknown parts\nwish_reading | deeper, cleaner (words: deeper, hollow) - the page's word match, a hint that can be wrong\nroom_limit | shared (a shared room or office): loudness 60 or under\ntiers | mod: foam, tape, lube, stabs; swap: switches, keycap_material, keycap_profile; rebuild: case, plate, mount\nallowed_to | switches: linear, tactile, clicky, silent-linear, silent-tactile; keycap_material: abs, pbt; keycap_profile: low, medium, tall, flat; case: plastic, aluminium, wood; plate: steel, aluminium, brass, polycarbonate, fr4, pom, plateless; mount: tray, top, gasket; foam: none, case, full; tape: none, tape; lube: stock, lubed; stabs: rattly, tuned\nverdict_rule | keep when there are no changes; otherwise the highest tier among the changes: rebuild if any change is case, plate or mount; else swap if any change is switches, keycap_material or keycap_profile; else tune (only foam, tape, lube, stabs)",
 "bother": "It sounds hollow, like typing on an empty box, and the spacebar rattles."
}

Reply:

{
 "task": "tune",
 "title": "Calming the Hollow, Keeping It Smooth",
 "now_sound": "With no foam inside a plastic case, this keyboard likely sounds hollow and empty, and rattly stabilisers make the spacebar rattle, just as you described.",
 "changes": [
  {
   "part": "foam",
   "to": "case",
   "effect": "deeper",
   "why": "Case foam fills the empty space inside, calming the hollow ring you hear and adding a touch of depth to each keystroke."
  },
  {
   "part": "lube",
   "to": "lubed",
   "effect": "smoother",
   "why": "Lube lets the parts inside each switch glide more smoothly, giving you the smooth feel you're after on every keystroke."
  },
  {
   "part": "stabs",
   "to": "tuned",
   "effect": "cleaner",
   "why": "Tuned stabilisers hold the spacebar steady so the rattle you hear drops out, leaving a cleaner, calmer sound under long keys."
  }
 ],
 "verdict": "tune",
 "verdict_reason": "These three mods - case foam, lubing and tuned stabilisers - reach smooth, deeper and ring-free sound without any swap or rebuild.",
 "room_note": "A shared room suits this well: these mods quiet the rattle and calm the hollow ring without adding loudness, so you should stay comfortable for those nearby.",
 "listen_for": "After adding case foam, listen for a deeper, less hollow tone under each key - the ring should fade even before the lube or stabiliser work."
}

A clicky board on open-mic calls - swap parts

Input:

{
 "task": "tune",
 "board": "{\"layout\":\"full\",\"switches\":\"clicky\",\"keycap_material\":\"abs\",\"keycap_profile\":\"medium\",\"case\":\"plastic\",\"plate\":\"steel\",\"mount\":\"tray\",\"foam\":\"none\",\"tape\":\"none\",\"lube\":\"stock\",\"stabs\":\"tuned\",\"hotswap\":\"yes\"}",
 "wish": "Quiet enough that my calls don't pick it up, but I still want to feel a little bump.",
 "room": "calls",
 "page_facts": "known_parts | 12 of 12 parts given\nestimate_now | pitch 78 of 100 (0 = deep thock, 100 = bright clack), loudness 71 of 100 - the page's rules of thumb, rougher with unknown parts\nwish_reading | quieter (words: quiet, clicking) - the page's word match, a hint that can be wrong\nroom_limit | calls (calls with an open mic): loudness 45 or under\ntiers | mod: foam, tape, lube, stabs; swap: switches, keycap_material, keycap_profile; rebuild: case, plate, mount\nallowed_to | switches: linear, tactile, clicky, silent-linear, silent-tactile; keycap_material: abs, pbt; keycap_profile: low, medium, tall, flat; case: plastic, aluminium, wood; plate: steel, aluminium, brass, polycarbonate, fr4, pom, plateless; mount: tray, top, gasket; foam: none, case, full; tape: none, tape; lube: stock, lubed; stabs: rattly, tuned\nverdict_rule | keep when there are no changes; otherwise the highest tier among the changes: rebuild if any change is case, plate or mount; else swap if any change is switches, keycap_material or keycap_profile; else tune (only foam, tape, lube, stabs)",
 "bother": "Everyone on my calls hears the clicking."
}

Reply:

{
 "task": "tune",
 "title": "Quiet Bump, Hold the Click",
 "now_sound": "Right now, clicky switches give you a sharp click on every press, loud enough that it's carrying straight into your call's open mic.",
 "changes": [
  {
   "part": "switches",
   "to": "silent-tactile",
   "effect": "quieter",
   "why": "The click comes from these switches; silent tactile ones still give a bump but swap out through hot-swap sockets, no soldering."
  }
 ],
 "verdict": "swap",
 "verdict_reason": "Swapping to silent tactile switches removes the click your calls hear while keeping the bump you want, without needing any other change.",
 "room_note": "This fits an open mic well: quiet switches with a bump should keep the clicking off your calls, but listen closely once it's changed.",
 "listen_for": "Listen for a soft, cushioned bump on each press instead of a click, and check it's quiet enough that your mic no longer picks it up."
}

A hard-bottoming board, wanted flexy - rebuild

Input:

{
 "task": "tune",
 "board": "{\"layout\":\"p60\",\"switches\":\"linear\",\"keycap_material\":\"abs\",\"keycap_profile\":\"low\",\"case\":\"plastic\",\"plate\":\"steel\",\"mount\":\"tray\",\"foam\":\"case\",\"tape\":\"none\",\"lube\":\"lubed\",\"stabs\":\"tuned\",\"hotswap\":\"no\"}",
 "wish": "Soft, bouncy, flexy typing that feels cushioned, with a deeper sound.",
 "room": "alone",
 "page_facts": "known_parts | 12 of 12 parts given\nestimate_now | pitch 66 of 100 (0 = deep thock, 100 = bright clack), loudness 45 of 100 - the page's rules of thumb, rougher with unknown parts\nwish_reading | deeper, quieter, bouncier (words: cushioned, soft, bouncy) - the page's word match, a hint that can be wrong\nroom_limit | alone (just me): no limit\ntiers | mod: foam, tape, lube, stabs; swap: switches, keycap_material, keycap_profile; rebuild: case, plate, mount\nallowed_to | switches: linear, tactile, clicky, silent-linear, silent-tactile; keycap_material: abs, pbt; keycap_profile: low, medium, tall, flat; case: plastic, aluminium, wood; plate: steel, aluminium, brass, polycarbonate, fr4, pom, plateless; mount: tray, top, gasket; foam: none, case, full; tape: none, tape; lube: stock, lubed; stabs: rattly, tuned\nverdict_rule | keep when there are no changes; otherwise the highest tier among the changes: rebuild if any change is case, plate or mount; else swap if any change is switches, keycap_material or keycap_profile; else tune (only foam, tape, lube, stabs)",
 "bother": "Every key bottoms out hard, like tapping on a table."
}

Reply:

{
 "task": "tune",
 "title": "From Stiff Thock to Soft Bounce",
 "now_sound": "Linear switches sit in a steel plate on a tray mount, so strokes stay stiff and each one bottoms out hard; case foam damps the case but doesn't cushion that landing.",
 "changes": [
  {
   "part": "foam",
   "to": "full",
   "effect": "deeper",
   "why": "Full foam adds padding under the plate and damps the case, pushing the sound deeper, as you asked for."
  },
  {
   "part": "mount",
   "to": "gasket",
   "effect": "bouncier",
   "why": "A gasket mount floats the plate, letting each stroke flex and cushion the bottom-out for that soft, bouncy feel; it needs a new case."
  }
 ],
 "verdict": "rebuild",
 "verdict_reason": "A new gasket-mount case brings the bouncy, cushioned flex you want, and full foam pulls the sound deeper, matching the rest of your wish.",
 "room_note": "Typing alone, there's no limit on how loud or bouncy this gets, so chase the full cushioned, deeper sound without holding back.",
 "listen_for": "After the foam, listen for a lower, duller tone with each strike; then feel for a softer, flexier landing once the new case arrives."
}

Build the input yourself

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

const K = KeyKit;
const built = K.buildInput({ room: "calls", wish: "a deep, quiet thock", board: { switches: "clicky", "case": "plastic", foam: "none", hotswap: "yes" } });
built.est;                                       // { pitch, loud, unknown } - the page's rules of thumb
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 tune-up
// K.estimate(K.applyChanges(built.board, reply.changes))  // the estimate after the changes

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://thock-or-clack.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":"thock-or-clack"}' | 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": "tune", "board": "{\"layout\":\"tkl\",\"switches\":\"linear\",\"keycap_material\":\"pbt\",\"keycap_profile\":\"medium\",\"case\":\"plastic\",\"plate\":\"steel\",\"mount\":\"tray\",\"foam\":\"none\",\"tape\":\"none\",\"lube\":\"stock\",\"stabs\":\"rattly\",\"hotswap\":\"unknown\"}", "wish": "Smooth and a bit deeper, without the hollow ring.", "room": "shared", "page_facts": "known_parts | 11 of 12 parts given; unknown: hotswap\nestimate_now | pitch 61 of 100 (0 = deep thock, 100 = bright clack), loudness 55 of 100 - the page's rules of thumb, rougher with unknown parts\nwish_reading | deeper, cleaner (words: deeper, hollow) - the page's word match, a hint that can be wrong\nroom_limit | shared (a shared room or office): loudness 60 or under\ntiers | mod: foam, tape, lube, stabs; swap: switches, keycap_material, keycap_profile; rebuild: case, plate, mount\nallowed_to | switches: linear, tactile, clicky, silent-linear, silent-tactile; keycap_material: abs, pbt; keycap_profile: low, medium, tall, flat; case: plastic, aluminium, wood; plate: steel, aluminium, brass, polycarbonate, fr4, pom, plateless; mount: tray, top, gasket; foam: none, case, full; tape: none, tape; lube: stock, lubed; stabs: rattly, tuned\nverdict_rule | keep when there are no changes; otherwise the highest tier among the changes: rebuild if any change is case, plate or mount; else swap if any change is switches, keycap_material or keycap_profile; else tune (only foam, tape, lube, stabs)", "bother": "It sounds hollow, like typing on an empty box, and the spacebar rattles."}
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 thock-or-clack:<task>:<inputHash>:a<attempt> (the one task is tune), 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="thock-or-clack:tune:$(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: thock-or-clack:tune:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json