Drive Sample Size Desk from your own code
Everything the web page does is available over HTTP: send a study description with the power facts you computed, and get the same sample-size justification back — or send the layout facts and get the design and randomisation section. The natural use is a protocol pipeline that regenerates the statistics paragraph whenever the effect size or the dropout assumption changes, or a pre-registration checker that refuses a draft whose numbers the engine cannot reproduce. The output is an AI-drafted aid for planning and education — not a substitute for a statistician or ethics committee review of a clinical trial or any regulated study.
One thing to be clear about before the first call: the model never computes statistics.
Sample size, power, the minimum detectable effect, the sensitivity table, the design effect and the
enrolment are computed by the caller and sent as facts. The model's job is judgement over
those facts — the basis for the effect size, the adjustments, the unit of randomisation, the prose. See
computing the facts yourself; the engine the web page uses ships as plain scripts
(/power.js, /design.js) you can load in node.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest endpoint takes {"slug":"sample-size-desk"} in its body), so
no slug header is needed afterwards — send your token as Authorization: Bearer … on every call.
The input object IS the request body. There is no {"input": …} wrapper. A
body that wraps the object returns a cheerful 200 and the lane never sees your task,
study or facts — post the object itself, exactly as the worked examples show it.
Error codes
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits. Call /estimate first and top up. |
forbidden | 403 | The token is valid but not for this app, or a guest token tried a metered run. Sign in for a personal token. |
not_found | 404 | Unknown job id, unknown collection, or the app slug does not exist. |
conflict | 409 | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
validation_error | 422 | The input object is missing a required field — task, study and facts — or a field is the wrong type. A body that is not valid JSON at all comes back as a 400. |
rate_limited | 429 | Too many requests. Back off and retry; do not tight-loop. |
internal | 5xx | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. Get a token
The easiest route is the token page: it shows the token this browser already holds,
with Copy token and Copy shell export buttons, and a sign-in button for a
personal token. A guest token can call /me and /estimate; both
lanes are metered, so they need a personal token from signing in.
# The token page is the shortest path: https://sample-size-desk.skillsafe.ai/tokens.html shows the token this
# browser holds and hands you a shell export. From the command line, mint a guest token
# (enough for /me and /estimate; running a lane needs a personal token from signing in):
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" -H "Content-Type: application/json" -d '{"slug": "sample-size-desk"}'
# -> {"ok":true,"data":{"token":"aut_...","subject_type":"guest",...}}
export SKILLSAFE_TOKEN="aut_..."
import json, urllib.request
req = urllib.request.Request("https://api.skillsafe.ai/v1/app-api/guest", data=json.dumps({"slug": "sample-size-desk"}).encode(), headers={"Content-Type": "application/json"}, method="POST")
token = json.load(urllib.request.urlopen(req))["data"]["token"] # guest: /me and /estimate only
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug: "sample-size-desk" }) });
const token = (await res.json()).data.token; // guest token: /me and /estimate only
resp, _ := http.Post("https://api.skillsafe.ai/v1/app-api/guest", "application/json", bytes.NewReader([]byte(`{"slug":"sample-size-desk"}`)))
var env struct{ Data struct{ Token string `json:"token"` } `json:"data"` }
json.NewDecoder(resp.Body).Decode(&env); token := env.Data.Token
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest")).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"sample-size-desk\"}")).build();
var body = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()).body();
String token = new ObjectMapper().readTree(body).at("/data/token").asText();
require "net/http"; require "json"
res = Net::HTTP.post(URI("https://api.skillsafe.ai/v1/app-api/guest"), { slug: "sample-size-desk" }.to_json, "Content-Type" => "application/json")
token = JSON.parse(res.body)["data"]["token"]
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_HTTPHEADER => ["Content-Type: application/json"], CURLOPT_POSTFIELDS => json_encode(["slug" => "sample-size-desk"]), CURLOPT_RETURNTRANSFER => true]);
$token = json_decode(curl_exec($ch), true)["data"]["token"];
var http = new HttpClient();
var res = await http.PostAsync("https://api.skillsafe.ai/v1/app-api/guest", new StringContent("{\"slug\":\"sample-size-desk\"}", Encoding.UTF8, "application/json"));
var token = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement.GetProperty("data").GetProperty("token").GetString();
2. A tiny client
# Every call is the same three things: the base URL, your bearer token, a JSON body.
call() { curl -sS -X "$1" "https://api.skillsafe.ai/v1/app-api/$2" -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" ${3:+--data-binary @"$3"}; }
# call GET me call POST estimate body.json call POST run body.json
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
def call(method, path, body=None, token="YOUR_TOKEN", headers=None):
data = json.dumps(body).encode() if body is not None else None
h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", **(headers or {})}
with urllib.request.urlopen(urllib.request.Request(f"{BASE}/{path}", data=data, headers=h, method=method)) as r:
env = json.load(r)
if not env.get("ok"): raise RuntimeError(env["error"])
return env["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
async function call(method, path, body, token = "YOUR_TOKEN", headers = {}) {
const r = await fetch(`${BASE}/${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...headers }, body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json(); if (!env.ok) throw Object.assign(new Error(env.error.message), env.error); return env.data;
}
const base = "https://api.skillsafe.ai/v1/app-api"
func call(method, path string, body any, token string, headers map[string]string) (map[string]any, error) {
var rd io.Reader; if body != nil { b, _ := json.Marshal(body); rd = bytes.NewReader(b) }
req, _ := http.NewRequest(method, base+"/"+path, rd)
req.Header.Set("Authorization", "Bearer "+token); req.Header.Set("Content-Type", "application/json")
for k, v := range headers { req.Header.Set(k, v) }
resp, err := http.DefaultClient.Do(req); if err != nil { return nil, err }; defer resp.Body.Close()
var env struct{ OK bool; Data map[string]any; Error map[string]any }; json.NewDecoder(resp.Body).Decode(&env)
if !env.OK { return nil, fmt.Errorf("%v", env.Error) }; return env.Data, nil
}
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static JsonNode call(String method, String path, Object body, String token, Map<String,String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + "/" + path)).header("Authorization", "Bearer " + token).header("Content-Type", "application/json");
headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(new ObjectMapper().writeValueAsString(body)));
var env = new ObjectMapper().readTree(HttpClient.newHttpClient().send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (!env.get("ok").asBoolean()) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
BASE = "https://api.skillsafe.ai/v1/app-api"
def call(method, path, body = nil, token: "YOUR_TOKEN", headers: {})
uri = URI("#{BASE}/#{path}"); req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{token}"; req["Content-Type"] = "application/json"; headers.each { |k, v| req[k] = v }
req.body = body.to_json if body
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s unless env["ok"]; env["data"]
end
const BASE = "https://api.skillsafe.ai/v1/app-api";
function call($method, $path, $body = null, $token = "YOUR_TOKEN", $headers = []) {
$h = array_merge(["Authorization: Bearer $token", "Content-Type: application/json"], $headers);
$ch = curl_init(BASE . "/$path");
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true] + ($body === null ? [] : [CURLOPT_POSTFIELDS => json_encode($body)]));
$env = json_decode(curl_exec($ch), true); if (!$env["ok"]) throw new Exception(json_encode($env["error"])); return $env["data"];
}
const string Base = "https://api.skillsafe.ai/v1/app-api";
static async Task<JsonElement> Call(string method, string path, object? body, string token, Dictionary<string,string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), $"{Base}/{path}");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (headers != null) foreach (var kv in headers) req.Headers.Add(kv.Key, kv.Value);
if (body != null) req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var env = JsonDocument.Parse(await (await new HttpClient().SendAsync(req)).Content.ReadAsStringAsync()).RootElement;
if (!env.GetProperty("ok").GetBoolean()) throw new Exception(env.GetProperty("error").ToString());
return env.GetProperty("data");
}
3. Check the session and the balance
/me returns only subject_type, subject_id and credits; a signed-in caller is subject_type == "user".
call GET me
# -> {"ok":true,"data":{"subject_type":"user","subject_id":"...","credits":48210}}
# subject_type is "user" for a personal token and "guest" for a guest one.
me = call("GET", "me", token=token)
print(me["subject_type"], me["credits"]) # "user" or "guest"; balance in credits (1 credit = $0.0001)
const me = await call("GET", "me", undefined, token);
console.log(me.subject_type, me.credits); // "user" | "guest"
me, _ := call("GET", "me", nil, token, nil)
fmt.Println(me["subject_type"], me["credits"])
var me = call("GET", "me", null, token, Map.of());
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = call("GET", "me", token: token)
puts "#{me["subject_type"]} #{me["credits"]}"
$me = call("GET", "me", null, $token);
echo $me["subject_type"], " ", $me["credits"];
var me = await Call("GET", "me", null, token);
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");
4. Price the run — free
/estimate creates no job and charges nothing. It returns the model binding (model,
model_alias, markup_bps) and hold_credits, the amount reserved for the
run — present it as reserved, never as the price; the settled charged_credits is
usually far lower. It does not validate the body shape: {} estimates cleanly,
so check task, study and facts yourself before posting.
The fields both lanes take
| field | type | meaning |
|---|---|---|
task | "justify" | "layout" | The lane. Required. |
study | string | What is compared, on what unit, with what primary outcome; nuisance factors. Required; the page clips the middle beyond 6,000 characters. |
effect_basis | sesoi | pilot | convention | unstated | Where the effect size comes from. |
effect_source | string | Its source, in a sentence. |
draft | string | Optional: the paragraph you already wrote, to be checked claim by claim. |
unit, measured, nuisance | string | Layout lane only: what is randomised, what is measured and how many per unit, the nuisance factors. |
facts | string (JSON) | Everything the engine computed. Required; see below. |
Computing the facts yourself
Load https://sample-size-desk.skillsafe.ai/power.js (and /design.js for the layout lane) in node with a stub
window, call Power.plan(spec), and send JSON.stringify of the result
as facts. The spec keys are test (t_ind, t_paired,
t_one, two_proportions, one_proportion, anova,
correlation, chi2, regression), effect (or
p1/p2/p0 for proportions), alpha, sides,
power, ratio, k, df, u, m_tests,
dropout, icc, cluster_size, solve
(n | mde | power) and n_available.
global.window = {};
require("./power.js"); require("./design.js");
const plan = window.Power.plan({ test: "t_ind", effect: 0.5, alpha: 0.05, sides: 2, power: 0.8, ratio: 1, dropout: 0.2, solve: "n" });
// layout lane: plan.layout = window.Recon.layoutFacts(window.Design.layout({ method: "stratified", strata: [{name:"site A",n:80},{name:"site B",n:80}], arms: ["exercise","usual care"], ratio: [1,1], seed: "knee-oa-2026-v1" }))
const facts = JSON.stringify(plan);
A facts object for the two-arm trial, abridged:
{
"test": "t_ind",
"test_label": "Two independent means (t test)",
"effect_name": "d",
"n_unit": "per group",
"warnings": [
"enrolment inflated for 20% dropout: enrol 160 to analyse 128"
],
"errors": [],
"effect": 0.5,
"alpha": 0.05,
"alpha_adjusted": 0.05,
"m_tests": 1,
"sides": 2,
"target_power": 0.8,
"ratio": 1,
"k": 3,
"df": 1,
"u": 1,
"v": 0,
"dropout": 0.2,
"icc": 0,
"cluster_size": 1,
"deff": 1,
"two_sided_convention": "both rejection tails counted (statsmodels; R strict=TRUE)",
"groups": 2,
"solve": "n",
"n": {
"n1": 64,
"n2": 64,
"total": 128,
"total_after_deff": 128,
"enrolled": 160,
"clusters_needed": null
},
"achieved_power": 0.8015,
"n_continuous_hint": 62.8,
"sensitivity": [
{
"factor": 0.6,
"effect": 0.3,
"n_per_group": 176,
"enrolled": 440
},
{
"factor": 1.0,
"effect": 0.5,
"n_per_group": 64,
"enrolled": 160
},
{
"factor": 1.6,
"effect": 0.8,
"n_per_group": 26,
"enrolled": 65
}
],
"power_curve": [
{
"n_per_group": 16,
"power": 0.276
},
{
"n_per_group": 64,
"power": 0.802
},
{
"n_per_group": 128,
"power": 0.98
}
],
"cross_check": {
"method": "normal (z) approximation",
"n_per_group": 63,
"note": "the z shortcut understates n for a t test; the difference shrinks as n grows"
}
}
The justify request body, in full
{
"task": "justify",
"study": "A two-arm randomised trial of a 12-week supervised exercise programme against usual care in adults with knee osteoarthritis. The unit is the participant; the primary outcome is WOMAC pain at 12 weeks.",
"effect_basis": "sesoi",
"effect_source": "MCID 9 points on 0-100, registry SD 18, so d = 0.5",
"draft": "",
"facts": "<the JSON string Power.plan returns - see step 4>"
}
# Save the run body as body.json - the object ITSELF, no {"input": ...} wrapper.
call POST estimate body.json
# -> {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,"hold_credits":3120,"min_credits":410,"sponsor_enabled":false}}
est = call("POST", "estimate", body, token=token)
print(est["hold_credits"], "reserved;", est["model_alias"], "->", est["model"]) # free, no job created
const est = await call("POST", "estimate", body, token);
console.log(est.hold_credits, "reserved;", est.model_alias, "->", est.model); // free
est, _ := call("POST", "estimate", body, token, nil)
fmt.Println(est["hold_credits"], est["model_alias"], est["model"])
var est = call("POST", "estimate", body, token, Map.of());
System.out.println(est.get("hold_credits") + " " + est.get("model"));
est = call("POST", "estimate", body, token: token)
puts "#{est["hold_credits"]} reserved on #{est["model"]}"
$est = call("POST", "estimate", $body, $token);
echo $est["hold_credits"], " reserved on ", $est["model"];
var est = await Call("POST", "estimate", body, token);
Console.WriteLine($"{est.GetProperty("hold_credits")} reserved on {est.GetProperty("model")}");
5. Run it, then poll
POST /run returns a job_id; poll GET /jobs/{id} until
status is succeeded, failed or cancelled. The reply is
data.output.output — the lane's JSON object as a string — and
data.charged_credits is what the run actually cost. If data.truncated is true the
balance sat between min_credits and hold_credits and the reply was cut short.
# Always send an Idempotency-Key derived from the input and the lane: a retried request with the
# same key is not billed twice. Then poll the job until it is terminal.
JOB=$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: sample-size-desk:justify:<sha256 of body>:a1" --data-binary @body.json | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
until call GET "jobs/$JOB" | grep -qE '"status":"(succeeded|failed|cancelled)"'; do sleep 2; done
call GET "jobs/$JOB" # data.output.output is the reply JSON as a STRING; data.charged_credits is what it cost
import hashlib, time
key = "sample-size-desk:" + body["task"] + ":" + hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:16] + ":a1"
job = call("POST", "run", body, token=token, headers={"Idempotency-Key": key})
while True:
j = call("GET", f"jobs/{job['job_id']}", token=token)
if j["status"] in ("succeeded", "failed", "cancelled"): break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the lane's JSON object
print(j["charged_credits"], "credits;", reply["verdict"], "-", reply["headline"])
const key = `sample-size-desk:${body.task}:${await sha16(JSON.stringify(body))}:a1`; // any stable hash of the body
const job = await call("POST", "run", body, token, { "Idempotency-Key": key });
let j; do { await new Promise(r => setTimeout(r, 2000)); j = await call("GET", `jobs/${job.job_id}`, undefined, token); } while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, "credits;", reply.verdict, "-", reply.headline);
job, _ := call("POST", "run", body, token, map[string]string{"Idempotency-Key": "sample-size-desk:justify:<sha256 of body>:a1"})
var j map[string]any
for { j, _ = call("GET", "jobs/"+job["job_id"].(string), nil, token, nil); s := j["status"].(string); if s == "succeeded" || s == "failed" || s == "cancelled" { break }; time.Sleep(2 * time.Second) }
var reply map[string]any; json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], reply["verdict"], reply["headline"])
var job = call("POST", "run", body, token, Map.of("Idempotency-Key", "sample-size-desk:justify:<sha256 of body>:a1"));
JsonNode j;
do { Thread.sleep(2000); j = call("GET", "jobs/" + job.get("job_id").asText(), null, token, Map.of()); } while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
var reply = new ObjectMapper().readTree(j.at("/output/output").asText());
System.out.println(j.get("charged_credits") + " credits; " + reply.get("verdict").asText() + " - " + reply.get("headline").asText());
job = call("POST", "run", body, token: token, headers: { "Idempotency-Key" => "sample-size-desk:justify:<sha256 of body>:a1" })
loop do
j = call("GET", "jobs/#{job["job_id"]}", token: token)
if %w[succeeded failed cancelled].include?(j["status"])
reply = JSON.parse(j["output"]["output"]); puts "#{j["charged_credits"]} credits; #{reply["verdict"]} - #{reply["headline"]}"; break
end
sleep 2
end
$job = call("POST", "run", $body, $token, ["Idempotency-Key: sample-size-desk:justify:<sha256 of body>:a1"]);
do { sleep(2); $j = call("GET", "jobs/" . $job["job_id"], null, $token); } while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " credits; ", $reply["verdict"], " - ", $reply["headline"];
var job = await Call("POST", "run", body, token, new() { ["Idempotency-Key"] = "sample-size-desk:justify:<sha256 of body>:a1" });
JsonElement j;
do { await Task.Delay(2000); j = await Call("GET", $"jobs/{job.GetProperty("job_id").GetString()}", null, token); } while (j.GetProperty("status").GetString() is not ("succeeded" or "failed" or "cancelled"));
var reply = JsonDocument.Parse(j.GetProperty("output").GetProperty("output").GetString()!).RootElement;
Console.WriteLine($"{j.GetProperty("charged_credits")} credits; {reply.GetProperty("verdict")} - {reply.GetProperty("headline")}");
The layout lane, in full
Same endpoints, same envelope, task: "layout", three extra strings, and facts.layout present:
{
"task": "layout",
"study": "(the same study text)",
"effect_basis": "sesoi",
"effect_source": "MCID 9 points, SD 18",
"draft": "",
"unit": "the participant",
"measured": "WOMAC pain at 12 weeks, one score per participant",
"nuisance": "site, assessor, season of enrolment",
"facts": "<Power.plan output with a `layout` key from Design.layout - see step 4>"
}
6. Or stream it
# Server-sent events. To curl, each `delta` frame carries a chunk of the reply; a browser page
# receives `tick` heartbeats and one final `done` instead. The final frame carries the whole job.
curl -sS -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Accept: text/event-stream" -H "Idempotency-Key: sample-size-desk:justify:<hash>:a1" --data-binary @body.json
req = urllib.request.Request("https://api.skillsafe.ai/v1/app-api/run-stream", data=json.dumps(body).encode(), method="POST",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Accept": "text/event-stream", "Idempotency-Key": key})
buf, event = "", None
for raw in urllib.request.urlopen(req):
line = raw.decode().rstrip("\n")
if line.startswith("event:"): event = line[6:].strip()
elif line.startswith("data:"):
d = json.loads(line[5:])
if event == "delta": buf += d.get("text", "")
elif event == "done": print("charged", d.get("charged_credits")); break
const r = await fetch("https://api.skillsafe.ai/v1/app-api/run-stream", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "text/event-stream", "Idempotency-Key": key }, body: JSON.stringify(body) });
const reader = r.body.getReader(), dec = new TextDecoder(); let buf = "", text = "";
for (;;) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true });
let i; while ((i = buf.indexOf("\n\n")) !== -1) { const frame = buf.slice(0, i); buf = buf.slice(i + 2);
const ev = /^event: ?(.*)$/m.exec(frame)?.[1], data = /^data: ?(.*)$/m.exec(frame)?.[1];
if (ev === "delta" && data) text += JSON.parse(data).text || ""; if (ev === "done") console.log("done", JSON.parse(data).charged_credits); } }
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/run-stream", bytes.NewReader(bodyJSON))
req.Header.Set("Authorization", "Bearer "+token); req.Header.Set("Content-Type", "application/json"); req.Header.Set("Accept", "text/event-stream"); req.Header.Set("Idempotency-Key", key)
resp, _ := http.DefaultClient.Do(req); sc := bufio.NewScanner(resp.Body); event := ""
for sc.Scan() { l := sc.Text(); if strings.HasPrefix(l, "event:") { event = strings.TrimSpace(l[6:]) } else if strings.HasPrefix(l, "data:") && event == "delta" { var d struct{ Text string }; json.Unmarshal([]byte(l[5:]), &d); text += d.Text } }
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/run-stream")).header("Authorization", "Bearer " + token).header("Content-Type", "application/json")
.header("Accept", "text/event-stream").header("Idempotency-Key", key).POST(HttpRequest.BodyPublishers.ofString(bodyJson)).build();
var lines = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofLines()).body();
String event = ""; var text = new StringBuilder();
for (String l : (Iterable<String>) lines::iterator) { if (l.startsWith("event:")) event = l.substring(6).trim(); else if (l.startsWith("data:") && event.equals("delta")) text.append(new ObjectMapper().readTree(l.substring(5)).path("text").asText()); }
uri = URI("https://api.skillsafe.ai/v1/app-api/run-stream"); req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{token}", "Content-Type" => "application/json", "Accept" => "text/event-stream", "Idempotency-Key" => key)
req.body = body.to_json; event = ""; text = +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| if l.start_with?("event:") then event = l[6..].strip elsif l.start_with?("data:") && event == "delta" then text << JSON.parse(l[5..])["text"].to_s end } } }
end
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/run-stream"); $text = ""; $event = "";
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($body), CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json", "Accept: text/event-stream", "Idempotency-Key: $key"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$text, &$event) { foreach (explode("\n", $chunk) as $l) { if (str_starts_with($l, "event:")) $event = trim(substr($l, 6)); elseif (str_starts_with($l, "data:") && $event === "delta") $text .= json_decode(substr($l, 5), true)["text"] ?? ""; } return strlen($chunk); }]);
curl_exec($ch);
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream") { Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") };
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); req.Headers.Accept.ParseAdd("text/event-stream"); req.Headers.Add("Idempotency-Key", key);
using var s = await (await new HttpClient().SendAsync(req, HttpCompletionOption.ResponseHeadersRead)).Content.ReadAsStreamAsync(); using var rd = new StreamReader(s);
string? l, ev = ""; var text = new StringBuilder();
while ((l = await rd.ReadLineAsync()) != null) { if (l.StartsWith("event:")) ev = l[6..].Trim(); else if (l.StartsWith("data:") && ev == "delta") text.Append(JsonDocument.Parse(l[5..]).RootElement.GetProperty("text").GetString()); }
7. Parse the reply
The reply JSON is a string inside the envelope, so unwrap it twice. The page's normaliser tolerates a missing array (treated as empty) and an enum outside the contract (coerced to the middle value), but not a missing statement (justify) or protocol_text (layout) — those trigger one reformat retry under the same Idempotency-Key family.
# The reply JSON is a string inside the envelope, so unwrap it twice:
call GET "jobs/$JOB" | python3 -c 'import sys,json; j=json.load(sys.stdin)["data"]; r=json.loads(j["output"]["output"]); print(r["lane"], r["verdict"]); print(r.get("statement") or r.get("protocol_text"))'
r = json.loads(j["output"]["output"])
assert r["lane"] == body["task"]
for k in ("title", "headline", "verdict", "summary", "notes_on_input", "risks", "next_steps", "draft_check"): assert k in r, k
if r["lane"] == "justify": print(r["statement"]); [print(a["name"], a["status"]) for a in r["adjustments"]]
else: print(r["protocol_text"]); [print(n["factor"], n["handling"]) for n in r["nuisance"]]
const r = JSON.parse(j.output.output);
if (r.lane !== body.task) console.warn("the model answered as", r.lane);
console.log(r.verdict, r.headline);
console.log(r.lane === "justify" ? r.statement : r.protocol_text);
var r struct{ Lane, Verdict, Headline, Statement, ProtocolText string `json:"lane"`; Adjustments []map[string]string }
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &r)
fmt.Println(r.Verdict, r.Headline)
var r = new ObjectMapper().readTree(j.at("/output/output").asText());
System.out.println(r.get("verdict").asText() + " - " + r.get("headline").asText());
r.withArray("risks").forEach(x -> System.out.println("risk: " + x.asText()));
r = JSON.parse(j["output"]["output"])
puts "#{r["verdict"]} - #{r["headline"]}"
puts r["lane"] == "justify" ? r["statement"] : r["protocol_text"]
$r = json_decode($j["output"]["output"], true);
echo $r["verdict"], " - ", $r["headline"], "\n", $r["lane"] === "justify" ? $r["statement"] : $r["protocol_text"];
var r = JsonDocument.Parse(j.GetProperty("output").GetProperty("output").GetString()!).RootElement;
Console.WriteLine($"{r.GetProperty("verdict")} - {r.GetProperty("headline")}");
The output contract
Every reply carries the common envelope, then the lane body.
"lane": "justify" | "layout", "title": string, "headline": string, "verdict": enum, "summary": string,
"notes_on_input": [string], "risks": [string], "next_steps": [string],
"draft_check": [ { "claim": string, "status": "agrees" | "disagrees" | "unverifiable", "note": string } ]
The justify lane
"verdict": "defensible" | "defensible_with_changes" | "not_defensible",
"statement": string,
"effect_basis": { "rating": "solid" | "weak" | "unsupported", "why": string, "better_basis": string },
"adjustments": [ { "name": "dropout" | "clustering" | "multiplicity" | "allocation" | "sidedness" | "analysis_match", "status": "applied" | "missing" | "not_needed", "note": string } ],
"sensitivity_reading": string,
"pitfalls": [ { "pitfall": string, "applies": "yes" | "no" | "unclear", "note": string } ]
The layout lane
"verdict": "sound" | "fixable" | "rework",
"design": { "name": string, "why": string },
"unit": { "randomised": string, "measured": string, "replicate": string, "pseudoreplication_risk": "yes" | "no" | "unclear", "note": string },
"nuisance": [ { "factor": string, "handling": "block" | "stratify" | "randomise_over" | "hold_constant" | "unaddressed", "how": string } ],
"schedule_reading": string, "protocol_text": string, "controls_and_blinding": [string]
Invariants worth asserting
laneequals thetaskyou sent; a mismatch means the router chose the other lane and the body follows that lane's contract.- Every number in
statement,protocol_text,schedule_readingandsensitivity_readingappears in thefactsyou sent — the model may only quote. The page'sRecon.reconcile(reply, facts)does this check; call it yourself. - In the justify lane,
statementquotesfacts.n.n1,facts.alphaand — when dropout or a design effect applies —facts.n.enrolled. - In the layout lane,
protocol_textcontainsfacts.layout.seedverbatim and the counts per arm.
Truncation and partial results
When the balance covers min_credits but not hold_credits, the run executes with a
reduced output cap and returns "truncated": true. The reply may then be cut mid-object; the
page closes the JSON and shows whatever sections parsed, marked as partial. Do the same, or top up and
re-run with a new Idempotency-Key suffix (:a2) so the retry is a deliberate second run.