← Sample Size Desk / API
Tokens

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

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate first and top up.
forbidden403The token is valid but not for this app, or a guest token tried a metered run. Sign in for a personal token.
not_found404Unknown job id, unknown collection, or the app slug does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
validation_error422The 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_limited429Too many requests. Back off and retry; do not tight-loop.
internal5xxA 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_..."

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

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.

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

fieldtypemeaning
task"justify" | "layout"The lane. Required.
studystringWhat is compared, on what unit, with what primary outcome; nuisance factors. Required; the page clips the middle beyond 6,000 characters.
effect_basissesoi | pilot | convention | unstatedWhere the effect size comes from.
effect_sourcestringIts source, in a sentence.
draftstringOptional: the paragraph you already wrote, to be checked claim by claim.
unit, measured, nuisancestringLayout lane only: what is randomised, what is measured and how many per unit, the nuisance factors.
factsstring (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}}

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

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

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"))'

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

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.