Drive Elbow Room from your own code
The one metered task, fit, is available over HTTP. It takes your height (or three measured lengths), today's seat, desk, keyboard and screen measurements, the hours you sit there, any strain spots and the things you have to hand, together with the page's own plan, and returns one short step per part to change, in the page's order, with the reason and what to use, plus lines on what to leave alone, the strain spots and breaks.
The division of labour first: the page decides; the model words it. The page works out every target, decides which parts are out of range, fixes the order (seat, footrest, keyboard, screen, distance, laptop split) and gives the verdict. It sends all of that as facts. The model never changes a target, the order or the verdict.
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":"elbow-room"}), 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, unit, body, setup, hours, 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: the reasons are metered and need 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: fit (the one task is fit); a missing or different one is still answered as fit. All fields are strings.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always fit. |
unit | yes | cm or in; every length uses it. |
body | yes | Height and the three lengths the targets come from (floor to back of knee with shoes, seat to elbow, seat to eye), each marked measured or estimated from height. |
setup | yes | The kind of setup and whether the chair and the desk heights adjust. |
hours | yes | About how many hours a day at this desk. |
strain | no | Strain spots (neck, shoulders, wrists or forearms, lower back, eyes) or none ticked. |
on_hand | no | Things to hand, at most 200 characters, or none. |
facts | yes | The page's lines: the verdict and why, the order of fixes, the parts in range, one line per part (now, target, change and the suggested means), a strain line and a breaks line. |
retry_note | no | Only on a reformat retry: why the previous reply was unusable. |
The verdict, first match wins: a laptop on its own for 2 hours or more a day is rework; a seat, keyboard or screen change of 10 cm or more is rework; any other part out of range is tweak; otherwise fits. A change of 2.5 cm or less counts as in range.
The reply
One JSON object, no prose around it:
{"task": "fit", "verdict": "tweak", "headline": "...", "steps": [{"part": "chair", "do": "...", "why": "...", "using": "..."}, ...], "keep": "...", "strain": "...", "habit": "...", "closing": "..."}
| Field | What it holds |
|---|---|
verdict | The verdict from facts, copied exactly. |
headline | At most 20 words: the verdict in plain words. |
steps[] | One per part in the order of fixes (chair, feet, keyboard, screen, distance, laptop), each with do (the change with the page's numbers), why and using (something to hand, or the page's suggested means). Empty when nothing changes. |
keep | The parts in range, or an empty string. |
strain | Each strain spot and the part it is tied to, or an empty string. |
habit | One line on breaks. |
closing | One line: sit as usual for a while, measure again and check again. |
The page then checks the reply (FitKit.check) - the verdict against its own, one step for every part to change and none for parts in range, every number one the page gave, no health claims, brands, time words you did not give, contact details or internal ids - and lists anything it finds under "The page's check".
Worked examples - every verdict
These are the 4 free samples on the page: the exact input the page sends and the reply that replays for free. Together they cover every verdict and both rework rules.
Laptop at the kitchen table - rework it (rule 1)
Input:
{
"task": "fit",
"unit": "cm",
"body": "height 175 cm; lower leg with shoes 46 cm (estimated from height); seat to elbow 25 cm (estimated from height); seat to eye 79 cm (estimated from height)",
"setup": "A laptop on its own; chair height adjustable: no; desk height adjustable: no",
"hours": "5",
"strain": "neck, lower back",
"on_hand": "a spare keyboard and mouse in a drawer, a few thick hardback books, a sofa cushion",
"facts": "verdict: rework\nwhy: rule 1 - a laptop on its own is used for 2 hours or more a day\norder of fixes: chair, feet, screen, distance, laptop\nin range: keyboard\nchair: seat height now 46 cm, target 55 cm; change: raise by 9 cm - a firm cushion on the seat\nfeet: now no footrest, target a footrest 8 cm high; change: add a footrest 8 cm high - a footrest or a sturdy box\nkeyboard: keyboard surface height (on the desk) now 76 cm, target 76 cm; in range\nscreen: screen top edge now 100 cm, target 133 cm (eye level); change: raise by 33 cm - a laptop stand\ndistance: now 45 cm from the eyes, target 53 to 77 cm; change: move back by 8 cm to 53 cm - nothing extra, the screen itself slides\nlaptop: a laptop used on its own, so its screen and keys cannot both be at the right height; change: split them - a separate keyboard and mouse, with the laptop raised\nstrain: neck -> screen (to fix); lower back -> chair (to fix)\nbreaks: about 5 hours a day at this desk - a short break or change of activity of 5 to 10 minutes every hour"
}
Reply:
{
"task": "fit",
"verdict": "rework",
"headline": "Your setup needs some reworking, beginning with raising the seat by 9 cm to 55 cm.",
"steps": [
{
"part": "chair",
"do": "Raise the seat by 9 cm to the target height of 55 cm.",
"why": "This keeps your thighs evenly supported while leaving the keyboard within easy reach.",
"using": "a sofa cushion"
},
{
"part": "feet",
"do": "Add a footrest that is 8 cm high under your feet.",
"why": "With a higher seat, this keeps your feet resting flat instead of hanging unsupported.",
"using": "a few thick hardback books"
},
{
"part": "screen",
"do": "Raise the screen's top edge by 33 cm to reach eye level at 133 cm.",
"why": "This lines up the screen's top edge with your eyes, helping your head stay level.",
"using": "a laptop stand"
},
{
"part": "distance",
"do": "Move the screen back by 8 cm to 53 cm from your eyes.",
"why": "This moves the screen back enough that you can take in the whole screen without leaning forward.",
"using": "nothing extra needed"
},
{
"part": "laptop",
"do": "Split the laptop's screen from its keys by using a separate keyboard and raising the laptop.",
"why": "On a laptop, the screen and the keyboard cannot both sit at the ideal height together.",
"using": "a spare keyboard and mouse in a drawer"
}
],
"keep": "The keyboard height is already in range, so you can leave it as it is.",
"strain": "The neck is tied to the screen, which is changing, and the lower back is tied to the chair, which is changing, so both may feel easier afterward.",
"habit": "About 5 hours a day at this desk calls for a short break or change of activity of 5 to 10 minutes every hour, getting up and moving around.",
"closing": "After you make these changes, sit as usual for a while, then measure again and run this check again."
}
Dining chair, far screen - rework it (rule 2)
Input:
{
"task": "fit",
"unit": "cm",
"body": "height 170 cm; lower leg with shoes 46 cm (measured); seat to elbow 24 cm (measured); seat to eye 77 cm (measured)",
"setup": "A separate screen and keyboard; chair height adjustable: no; desk height adjustable: no",
"hours": "3",
"strain": "shoulders, eyes",
"on_hand": "a firm seat cushion",
"facts": "verdict: rework\nwhy: rule 2 - the screen needs a change of 10 cm or more\norder of fixes: chair, feet, screen, distance\nin range: keyboard\nchair: seat height now 44 cm, target 51 cm; change: raise by 7 cm - a firm cushion on the seat\nfeet: now no footrest, target a footrest 5 cm high; change: add a footrest 5 cm high - a footrest or a sturdy box\nkeyboard: keyboard surface height (on the desk) now 72 cm, target 72 cm; in range\nscreen: screen top edge now 108 cm, target 128 cm (eye level); change: raise by 20 cm - a monitor riser or a stack of books\ndistance: now 90 cm from the eyes, target 51 to 75 cm; change: move closer by 15 cm to 75 cm - nothing extra, the screen itself slides\nlaptop: not a laptop on its own\nstrain: shoulders -> keyboard (in range); eyes -> distance (to fix)\nbreaks: about 3 hours a day at this desk - a short break or change of activity of 5 to 10 minutes every hour"
}
Reply:
{
"task": "fit",
"verdict": "rework",
"headline": "Your setup needs reworking, starting with raising the seat height.",
"steps": [
{
"part": "chair",
"do": "Raise the seat height by 7 cm to reach 51 cm.",
"why": "This lets your thighs rest evenly on the seat while the keyboard stays within easy reach.",
"using": "a firm seat cushion"
},
{
"part": "feet",
"do": "Add a footrest 5 cm high.",
"why": "Once the seat is higher, your feet can stay flat on the footrest instead of hanging in the air.",
"using": "a footrest or a sturdy box"
},
{
"part": "screen",
"do": "Raise the screen top edge by 20 cm to reach 128 cm.",
"why": "This lines up the top of the screen with your eye height, helping your head stay level.",
"using": "a monitor riser or a stack of books"
},
{
"part": "distance",
"do": "Move the screen closer by 15 cm to 75 cm.",
"why": "This distance keeps the whole screen within view, about an arm's length away, without needing to lean in.",
"using": "nothing extra"
}
],
"keep": "The keyboard height is already right, so leave it as it is.",
"strain": "Shoulder strain is tied to the keyboard, which is already in range and not changing; eye strain is tied to the screen distance, which is in the plan and may become easier.",
"habit": "At about 3 hours a day at this desk, take a short break or change of activity for 5 to 10 minutes every hour, getting up and moving around.",
"closing": "Once you make these changes, sit as usual for a while, then measure again and run this check again."
}
Study desk, short legs - a few tweaks (rule 3)
Input:
{
"task": "fit",
"unit": "cm",
"body": "height 162 cm; lower leg with shoes 43 cm (estimated from height); seat to elbow 23 cm (estimated from height); seat to eye 73 cm (estimated from height)",
"setup": "A separate screen and keyboard; chair height adjustable: yes; desk height adjustable: no",
"hours": "6",
"strain": "wrists or forearms",
"on_hand": "a shoebox, a yoga block",
"facts": "verdict: tweak\nwhy: rule 3 - at least one part is out of range and each change is under 10 cm\norder of fixes: chair, feet, screen\nin range: keyboard, distance\nchair: seat height now 48 cm, target 54 cm; change: raise by 6 cm - the chair's height lever\nfeet: now no footrest, target a footrest 11 cm high; change: add a footrest 11 cm high - a footrest or a sturdy box\nkeyboard: keyboard surface height (on the desk) now 74 cm, target 74 cm; in range\nscreen: screen top edge now 120 cm, target 127 cm (eye level); change: raise by 7 cm - a monitor riser or a stack of books\ndistance: now 58 cm from the eyes, target 49 to 71 cm; in range\nlaptop: not a laptop on its own\nstrain: wrists or forearms -> keyboard (in range)\nbreaks: about 6 hours a day at this desk - a short break or change of activity of 5 to 10 minutes every hour"
}
Reply:
{
"task": "fit",
"verdict": "tweak",
"headline": "A few small changes are needed: raise the chair, add a footrest and raise the screen.",
"steps": [
{
"part": "chair",
"do": "Raise the chair's seat height by 6 cm to the target of 54 cm.",
"why": "This keeps your thighs evenly supported while keeping the keyboard within reach.",
"using": "the chair's height lever"
},
{
"part": "feet",
"do": "Add a footrest that is 11 cm high.",
"why": "With the seat higher, the footrest lets your feet rest flat instead of hanging in the air.",
"using": "a shoebox"
},
{
"part": "screen",
"do": "Raise the top edge of the screen by 7 cm to the target of 127 cm.",
"why": "Having the top edge level with your eyes lets your head stay level.",
"using": "a yoga block"
}
],
"keep": "The keyboard and the screen distance are already in range, so leave them as they are.",
"strain": "The wrists or forearms are linked to the keyboard, which is already in range and is not changing.",
"habit": "At about 6 hours a day at this desk, take a short break or change of activity of 5 to 10 minutes every hour, getting up and moving around.",
"closing": "Once these changes are in place, sit normally for a while, then measure your setup again and repeat this check."
}
Tall, sit-stand desk (inches) - fits you (rule 4)
Input:
{
"task": "fit",
"unit": "in",
"body": "height 74 in; lower leg with shoes 19.5 in (estimated from height); seat to elbow 10.5 in (estimated from height); seat to eye 33.5 in (estimated from height)",
"setup": "A raised laptop with a separate keyboard; chair height adjustable: yes; desk height adjustable: yes",
"hours": "8",
"strain": "none ticked",
"on_hand": "none",
"facts": "verdict: fits\nwhy: rule 4 - every part is in range\norder of fixes: none\nin range: chair, keyboard, screen, distance\nchair: seat height now 20 in, target 20 in; in range\nfeet: now no footrest; in range\nkeyboard: keyboard surface height (on the desk) now 29.5 in, target 29 in; in range\nscreen: screen top edge now 52 in, target 53.5 in (eye level); in range\ndistance: now 27 in from the eyes, target 22 to 32.5 in; in range\nlaptop: not a laptop on its own\nstrain: none ticked\nbreaks: about 8 hours a day at this desk - a short break or change of activity of 5 to 10 minutes every hour"
}
Reply:
{
"task": "fit",
"verdict": "fits",
"headline": "Everything about your setup already fits, with no changes needed.",
"steps": [],
"keep": "The chair, keyboard, screen and screen distance are all in range, so leave them as they are.",
"strain": "",
"habit": "At about 8 hours a day at this desk, take a short break or change of activity of 5 to 10 minutes every hour, getting up and moving around.",
"closing": "If you make changes later, keep sitting normally for a while, then take fresh measurements and run this check again."
}
Build the input yourself
Load the engine in node (global.window = global; require("./fitkit.js")), then:
const K = FitKit;
const form = K.form({ unit: "cm", height: "170", setup: "monitor", seat: "45", desk: "74", screen: "112", dist: "65",
chairAdj: true, deskAdj: false, hours: "6", strain: { neck: true }, onHand: "a few thick books" });
const built = K.buildInput(form); // built.facts holds the targets, the order and the verdict
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.md(built, reply, K.title(form)) // the plan as Markdown
Form keys: unit (cm/in), height, optional leg, elbow, eye, setup (laptop, laptop-plus, monitor), seat, desk, optional kb (a tray) and foot, screen (top edge from the floor), dist (eyes to screen), chairAdj, deskAdj, hours, strain (neck, shoulders, wrists, back, eyes) and onHand.
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://elbow-room.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://elbow-room.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"]
// elbow-room.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://elbow-room.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://elbow-room.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
}
// ElbowRoom.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 ElbowRoom {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://elbow-room.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://elbow-room.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://elbow-room.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://elbow-room.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":"elbow-room"}' | 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": "elbow-room"})["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: "elbow-room" })).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": "elbow-room"}, 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", "elbow-room"), 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: "elbow-room" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "elbow-room"])["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 = "elbow-room" }))!["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": "fit", "unit": "cm", "body": "...", "setup": "...", "hours": "6", "strain": "...", "on_hand": "...", "facts": "...", ...}
# - 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": "fit", "unit": "cm", "body": "...", "setup": "...", "hours": "6", "strain": "...", "on_hand": "...", "facts": "...", ...}
# - 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: "fit", unit, body, setup, hours, strain, on_hand, 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 elbow-room:<task>:<inputHash>:a<attempt> (the one task is fit), 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="elbow-room:fit:$(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 = "elbow-room:" + 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 = `elbow-room:${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 := "elbow-room:" + 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 = "elbow-room:" + 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 = "elbow-room:#{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 = "elbow-room:" . $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 = $"elbow-room:{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: elbow-room:fit:$(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": "fit", "unit": "cm", "body": "...", "setup": "...", "hours": "6", "strain": "...", "on_hand": "...", "facts": "...", ...}
# - 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": "elbow-room:fit:" + 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": `elbow-room:fit:${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", "elbow-room:fit:"+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", "elbow-room:fit:" + 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" => "elbow-room:fit:#{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: elbow-room:fit:" . 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", $"elbow-room:fit:{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);