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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field (task, board, wish, room, page_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 tune-up 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 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).
| Field | Required | What it holds |
|---|---|---|
task | yes | Always tune. |
board | yes | A 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. |
wish | yes | The sound and feel wanted, in the person's words (up to 400 characters). |
room | yes | alone, shared, calls or sleeper. |
page_facts | yes | The page's notes, one key | ... per line: known_parts, estimate_now, wish_reading (a hint only), room_limit, tiers, allowed_to and verdict_rule. |
bother | no | What bugs the person now (up to 300 characters). |
clip_note | no | What the page cut, if anything. |
retry_note | no | Only 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": "..."}
| Field | What it holds |
|---|---|
now_sound | How the board likely sounds and feels now, from its known parts (at most 35 words). |
changes | 0 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_reason | The rule above, and one sentence (at most 30 words). |
room_note / listen_for / title | Whether 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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://thock-or-clack.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"]
// thock-or-clack.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://thock-or-clack.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://thock-or-clack.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
}
// ThockOrClack.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 ThockOrClack {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://thock-or-clack.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://thock-or-clack.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://thock-or-clack.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://thock-or-clack.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":"thock-or-clack"}' | 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": "thock-or-clack"})["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: "thock-or-clack" })).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": "thock-or-clack"}, 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", "thock-or-clack"), 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: "thock-or-clack" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "thock-or-clack"])["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 = "thock-or-clack" }))!["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": "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}'
inp = json.load(open("input.json")) # {"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."}
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: "tune", board, wish, room, page_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 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}'
import hashlib, time
key = "thock-or-clack:" + 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("title"))
import { createHash } from "node:crypto";
const key = `thock-or-clack:${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.title);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "thock-or-clack:" + 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 = "thock-or-clack:" + 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 = "thock-or-clack:#{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["title"]].inspect
$key = "thock-or-clack:" . $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["title"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"thock-or-clack:{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: thock-or-clack:tune:$(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": "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."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "thock-or-clack:tune:" + 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": `thock-or-clack:tune:${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", "thock-or-clack:tune:"+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", "thock-or-clack:tune:" + 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" => "thock-or-clack:tune:#{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: thock-or-clack:tune:" . 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", $"thock-or-clack:tune:{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);