Drive Experiment Desk from your own code
Everything the browser computes — every statistical test, the intervals, the power and
sample-size arithmetic, the sample-ratio check, the duration and peeking checks, the Holm correction and the
decision rule — is deterministic and runs client-side, so the API surface here is the metered judgement
pass plus the storage the app saves its readouts to. Every request goes to
https://api.skillsafe.ai/v1/app-api and carries a bearer token.
Base URL, envelope and errors
Every response is a JSON envelope. Success carries data; failure carries error
with a stable code. Nothing else appears at the top level, so a client can branch on the presence
of error alone.
{ "ok": true, "data": { "...": "..." } }
{ "ok": false, "error": { "code": "insufficient_credits", "message": "...", "details": { } } }
| Code | HTTP | What it means | What to do |
|---|---|---|---|
unauthorized | 401 | No token, or the token was minted for another app. | Mint a guest token or sign in; see step 2. |
insufficient_credits | 402 | The balance is below min_credits. | Top up. /estimate is free, so check before you submit. |
validation_error | 400 | The input JSON is not the shape the app declares. | Compare against the input schema in step 4. |
rate_limited | 429 | Too many requests. Similarity search is the tightest at 30/min per IP. | Back off and retry; do not tight-loop. |
payload_too_large | 413 | The run input exceeded 1 MB of JSON. | Clip the arm and guardrail tables on whole rows and keep the header. |
upstream_error | 502/529 | The model provider failed or was overloaded. | Retry with the SAME Idempotency-Key so a partial charge is not repeated. |
1. A tiny client
Every call is the same three lines: a bearer token, a JSON body, and the envelope unwrapped. Read the token
from your environment or your secret store — the snippets below use a "YOUR_TOKEN" placeholder,
and the token page will show you yours and copy a shell export for you, so you never
have to open a developer console.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"
call() { # call <path> <json-body>
curl -sS -X POST "$BASE$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
}
import json, os, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def call(path, body=None, method="POST"):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
env = json.load(r)
if not env.get("ok"):
raise RuntimeError(env["error"]["code"] + ": " + env["error"]["message"])
return env["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
async function call(path, body, method = "POST") {
const res = await fetch(BASE + path, {
method,
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json"
},
body: body === undefined ? undefined : JSON.stringify(body)
});
const env = await res.json();
if (!env.ok) throw new Error(`${env.error.code}: ${env.error.message}`);
return env.data;
}
package main
import (
"bytes"
"encoding/json"
"errors"
"net/http"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const token = "YOUR_TOKEN"
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", base+path, bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, errors.New(env.Error.Code + ": " + env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class TamDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN";
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
return res.body(); // a JSON envelope: { ok, data } or { ok, error }
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"
def call(path, body = nil, method = :post)
uri = URI(BASE + path)
klass = method == :get ? Net::HTTP::Get : Net::HTTP::Post
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.dump(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
env = JSON.parse(res.body)
raise "#{env["error"]["code"]}: #{env["error"]["message"]}" unless env["ok"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
function call(string $path, ?array $body = null, string $method = "POST") {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
],
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$env = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($env["ok"])) {
throw new RuntimeException($env["error"]["code"] . ": " . $env["error"]["message"]);
}
return $env["data"];
}
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var BASE = "https://api.skillsafe.ai/v1/app-api";
var TOKEN = "YOUR_TOKEN";
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", TOKEN);
async Task<JsonElement> Call(string path, object? body = null)
{
var content = new StringContent(JsonSerializer.Serialize(body ?? new { }),
Encoding.UTF8, "application/json");
var res = await http.PostAsync(BASE + path, content);
var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!env.GetProperty("ok").GetBoolean())
throw new Exception(env.GetProperty("error").GetProperty("code").GetString());
return env.GetProperty("data");
}
2. A token
A guest token is minted per app and needs the slug in the body — an
X-App-Slug header returns 400. A guest can browse and estimate; running the metered pass needs a
signed-in personal token, which the token page will hand you along with a ready-made
shell export. Note that acl_read: "owner" scopes stored records to the calling subject, and every
/guest call mints a new subject — reuse one token across create and query or you
will read an empty collection.
curl -sS -X POST "$BASE/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"experiment-desk"}'
# -> { "ok": true, "data": { "token": "...", "subject_type": "guest" } }
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest",
data=json.dumps({"slug": "experiment-desk"}).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "experiment-desk" })
});
const { data } = await res.json();
const TOKEN = data.token;
body := bytes.NewBufferString(`{"slug":"experiment-desk"}`)
res, _ := http.Post(base+"/guest", "application/json", body)
defer res.Body.Close()
var env struct {
Data struct{ Token string } `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
guestToken := env.Data.Token
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"experiment-desk\"}"))
.build();
String envelope = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
// envelope.data.token
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
res = Net::HTTP.post(uri, JSON.dump({ slug: "experiment-desk" }),
"Content-Type" => "application/json")
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["slug" => "experiment-desk"]),
]);
$env = json_decode(curl_exec($ch), true);
$token = $env["data"]["token"];
var guestBody = new StringContent("{\"slug\":\"experiment-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await new HttpClient().PostAsync(
"https://api.skillsafe.ai/v1/app-api/guest", guestBody);
var guestEnv = JsonDocument.Parse(await guestRes.Content.ReadAsStringAsync()).RootElement;
var token = guestEnv.GetProperty("data").GetProperty("token").GetString();
3. Who am I, and what is the balance
GET /me is free and is what the app uses for its credit preflight: compare
credits against the min_credits that /estimate returns and refuse to
submit rather than collecting a 402 afterwards.
curl -sS "$BASE/me" -H "Authorization: Bearer $TOKEN"
# -> { "ok": true, "data": { "subject_type": "user", "credits": 250000 } }
me = call("/me", method="GET")
print(me["subject_type"], me["credits"])
const me = await call("/me", undefined, "GET");
console.log(me.subject_type, me.credits);
req, _ := http.NewRequest("GET", base+"/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
HttpRequest me = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/me"))
.header("Authorization", "Bearer " + TOKEN)
.GET()
.build();
System.out.println(HTTP.send(me, HttpResponse.BodyHandlers.ofString()).body());
me = call("/me", nil, :get)
puts me["subject_type"], me["credits"]
<?php
$me = call("/me", null, "GET");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var meReq = new HttpRequestMessage(HttpMethod.Get, BASE + "/me"); var meRes = await http.SendAsync(meReq); Console.WriteLine(await meRes.Content.ReadAsStringAsync());
4. The input this app accepts
Taken from buildInput() in app.js, not from intent. The facts object
is the whole client-side measurement and is what makes the model accountable: it is the authority on every
number, and the app re-checks the reply against it afterwards. If you are driving the API without running the
browser engine, send whatever of facts you can compute — but understand that the model is
instructed to return exactly one reading per entry in facts.metrics_for_review, so an empty list
yields an empty review.
{
"surface": "web | product | email | pricing | ml",
"note": "free-text steer, <= 500 chars",
"design_block": "the key: value design (middle-clipped at 3000 chars)",
"arms_excerpt": "the arm table, cut on WHOLE ROWS with the header kept (6000 chars)",
"guards_excerpt": "the guardrail table, same treatment (6000 chars)",
"facts": {
"experiment": "string",
"hypothesis": "string",
"unit": "user | session | workspace | ...",
"alpha": 0.05, "alpha_assumed": false,
"power_target": 0.8, "power_assumed": false,
"primary": {
"name": "checkout completion rate",
"good_direction": "up | down",
"good_direction_declared": true,
"control_rate": 0.06598, "variant_rate": 0.07269,
"diff": 0.00671, "rel": 0.1017, "p": 0.0000418,
"ci": [0.00350, 0.00992], "significant": true,
"n_control": 48210, "n_variant": 48067,
"achieved_power": 0.983, "required_n": 90942,
"detectable_abs": 0.00456, "detectable_rel": 0.0692,
"settle_n": 22508, "shortfall_per_arm": null, "extra_days": null
},
"guardrails": [
{ "id": "g1", "name": "Refund rate", "control": 0.021, "variant": 0.0224,
"diff": 0.0014, "rel": 0.0667, "good_direction": "up | down",
"computable": true, "p": 0.6952, "p_holm": 0.6952,
"degraded": true, "significant": false, "not_computable_why": null }
],
"holm_family_size": 2,
"sample_ratio": { "chi": 0.212, "df": 1, "p": 0.6449, "mismatch": false,
"planned_declared": true,
"rows": [ { "arm": "Control", "observed": 48210, "expected": 48138.5,
"share": 0.5007, "planned_share": 0.5 } ] },
"duration": { "days": 14, "weeks": 2, "whole_weeks": 2, "remainder_days": 0,
"covers_week": true, "whole_cycles": true, "per_day": 6880,
"level": "pass | warn | fail", "note": "string" },
"peeking": { "looks": 1, "alpha": 0.05, "inflated_upper": 0.05,
"pocock": null, "bonferroni": null,
"level": "pass | warn | fail", "note": "string" },
"metrics_for_review": [
{ "id": "primary", "name": "string", "role": "primary | guardrail",
"control": 0.06598, "variant": 0.07269, "diff": 0.00671, "rel": 0.1017,
"p": 0.0000418, "significant": true, "good": "up | down" }
],
"decision": { "key": "ship | investigate | extend | stop | revert | invalid",
"label": "string", "reasons": [ "string" ] },
"checks": [ { "level": "pass | warn | fail", "label": "string", "detail": "string" } ],
"gaps": [ "string" ],
"warnings": [ "string" ]
},
"current_datetime": "2026-08-08T09:00:00Z",
"retry_note": "optional - present only on the app's one reformat retry"
}
metrics_for_review. A guardrail row with no exposure counts still appears in
facts.guardrails with computable: false and a not_computable_why, so the
model can see it and mention it — but it is excluded from the partition, so the model is never marked down
for a gap the browser itself found and reported.5. The output contract
Taken from normalizeModel() and reconcile() in statkit.js. The model
returns exactly one JSON object with no fence and no prose. Entries with no metric_id are
dropped and unknown reading values are kept verbatim and failed by name, so a malformed
review is reported rather than quietly normalised into agreement.
{
"title": "Checkout single-page test - a real win with a refund rate worth watching",
"verdict_note": "two or three sentences a director could read alone",
"decision": "ship | investigate | extend | stop | revert | invalid",
"decision_rationale": "why that decision follows from the measurement",
"hypothesis_review": "is the hypothesis testable, and did this test test it",
"metric_reviews": [
{
"metric_id": "primary",
"reading": "win | loss | flat | inconclusive",
"comment": "what this metric means for the product"
}
],
"validity_threats": [ "string" ],
"next_experiment": "one If/Then/Because sentence",
"risks": [ "string" ],
"unverified": [ "string" ]
}
What the app asserts about that reply
These are counted, not sampled, and the results are rendered next to the model's own words.
| Check | Assertion | Fails when |
|---|---|---|
metric_partition | metric_reviews is a partition of facts.metrics_for_review: every id read exactly once. Missing, duplicated and off-contract ids are counted and reported separately. | An id is missing, read twice, or is not a measured metric at all. |
reading_vocabulary | Every reading is one of win, loss, flat, inconclusive. | Anything else — the offending string is shown verbatim. |
reading_matches_measurement | A significant move in the good direction is win, a significant move against it is loss, and a non-significant metric is flat or inconclusive. When the sample-ratio check has failed, every metric must be inconclusive — including one with a real p < 0.0001, because a p-value computed across arms that were never comparable is not evidence of anything. | The reply claims a result the arithmetic does not support — the measured p-value is rendered beside the claim. |
decision_vs_rule | decision is a legal key and is compared against the decision the engine's rule reached from the same facts. | They disagree — both are shown side by side rather than merged, and the rule is the one to defend. |
srm_surfaced | When the sample-ratio check failed, the reply mentions it. | The reply reads the metrics as if the assignment had worked. |
underpowering_surfaced | When the collected exposure was below the declared MDE's requirement, the reply acknowledges it. | Warns when it does not. |
engine_gaps_excluded | Metrics the engine could not test are never in the partition, so the model is not blamed for a gap the browser already reported. | Never — this one is informational by construction. |
unverified | The reply lists what it could not trace to the measurement. | Warns on an empty list, since that is a claim that everything is traceable. |
6. Estimate first — it is free
/estimate creates no job and charges nothing. hold_credits is what will be
reserved, not the price: it prices the full output cap, and the settled
charged_credits is usually far lower. If the balance sits between min_credits and
hold_credits the run still executes with a reduced cap and comes back
"truncated": true.
curl -sS -X POST "$BASE/estimate" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @input.json
# -> { "ok": true, "data": { "model": "gpt-5.6-terra", "model_alias": "gpt-terra",
# "markup_bps": 1000, "hold_credits": 2900, "min_credits": 120 } }
est = call("/estimate", payload)
print(est["model"], est["model_alias"], est["markup_bps"])
if me["credits"] < est["min_credits"]:
raise SystemExit("top up before running")
const est = await call("/estimate", payload);
if (me.credits < est.min_credits) throw new Error("top up before running");
console.log(`reserving up to ${est.hold_credits} credits on ${est.model}`);
data, err := call("/estimate", payload)
if err != nil {
log.Fatal(err)
}
var est struct {
Model string `json:"model"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
}
json.Unmarshal(data, &est)
String est = call("/estimate", inputJson);
System.out.println(est); // model, model_alias, markup_bps, hold_credits, min_credits
est = call("/estimate", payload)
abort("top up before running") if me["credits"] < est["min_credits"]
puts "reserving up to #{est["hold_credits"]} on #{est["model"]}"
<?php
$est = call("/estimate", $payload);
if ($me["credits"] < $est["min_credits"]) {
exit("top up before running\n");
}
var est = await Call("/estimate", payload);
Console.WriteLine(est.GetProperty("model").GetString());
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
7. Run it — with an Idempotency-Key
Pass a key derived from the input plus an attempt counter. The app uses
experiment-desk:<fnv1a-of-inputs>:<length>:a<attempt>, and its one automatic reformat
retry reuses a key derived from the same input, so a malformed first reply can never double-bill. Do the same:
a network blip that makes you retry must not become a second charge.
curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: experiment-desk:00328e8c:1358:a1" \
--data @input.json
# -> { "ok": true, "data": { "job_id": "job_...", "status": "queued" } }
curl -sS "$BASE/jobs/job_..." -H "Authorization: Bearer $TOKEN"
# poll until status is "succeeded" or "failed"
import time
req = urllib.request.Request(BASE + "/run", data=json.dumps(payload).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", "experiment-desk:00328e8c:1358:a1")
with urllib.request.urlopen(req) as r:
job = json.load(r)["data"]
while True:
j = call("/jobs/" + job["job_id"], method="GET")
if j["status"] in ("succeeded", "failed"):
break
time.sleep(1.5)
result = json.loads(j["output"]["output"])
print(len(result["metric_reviews"]), "metric readings")
const job = await fetch(`${BASE}/run`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": "experiment-desk:00328e8c:1358:a1"
},
body: JSON.stringify(payload)
}).then(r => r.json()).then(e => e.data);
let j;
do {
await new Promise(r => setTimeout(r, 1500));
j = await call(`/jobs/${job.job_id}`, undefined, "GET");
} while (j.status !== "succeeded" && j.status !== "failed");
const result = JSON.parse(j.output.output);
b, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "experiment-desk:00328e8c:1358:a1")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
// then poll GET /jobs/{job_id} until status is terminal
HttpRequest run = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "experiment-desk:00328e8c:1358:a1")
.POST(HttpRequest.BodyPublishers.ofString(inputJson))
.build();
String job = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
// then poll GET /jobs/{job_id}
uri = URI(BASE + "/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "experiment-desk:00328e8c:1358:a1"
req.body = JSON.dump(payload)
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
loop do
j = call("/jobs/#{job["job_id"]}", nil, :get)
break if %w[succeeded failed].include?(j["status"])
sleep 1.5
end
<?php
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: experiment-desk:00328e8c:1358:a1",
],
CURLOPT_POSTFIELDS => json_encode($payload),
]);
$job = json_decode(curl_exec($ch), true)["data"];
// then poll GET /jobs/{job_id}
var runMsg = new HttpRequestMessage(HttpMethod.Post, BASE + "/run")
{
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
};
runMsg.Headers.Add("Idempotency-Key", "experiment-desk:00328e8c:1358:a1");
var runRes = await http.SendAsync(runMsg);
// then poll GET /jobs/{job_id}
8. Streaming, if you want the progress
The SDK exposes runStream, and the app uses it to advance its staged progress card off real
signals — each field name appearing in the delta stream moves it on. Frame names arrive on the
event: line, and the payload on data:. The same
Idempotency-Key discipline applies.
curl -N -sS -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: experiment-desk:00328e8c:1358:a1" \
--data @input.json
# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"{\"title\":\"Checkout single-page test"}
# event: done
# data: {"status":"succeeded","charged_credits":812,"truncated":false}
req = urllib.request.Request(BASE + "/run-stream", data=json.dumps(payload).encode())
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", "experiment-desk:00328e8c:1358:a1")
raw, event = "", None
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:") and event == "delta":
raw += json.loads(line[5:])["text"]
result = json.loads(raw)
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": "experiment-desk:00328e8c:1358:a1"
},
body: JSON.stringify(payload)
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop();
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:") && event === "delta") raw += JSON.parse(line.slice(5)).text;
}
}
const result = JSON.parse(raw);
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "experiment-desk:00328e8c:1358:a1")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
var event, raw string
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:") && event == "delta":
var d struct{ Text string }
json.Unmarshal([]byte(line[5:]), &d)
raw += d.Text
}
}
HttpRequest stream = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "experiment-desk:00328e8c:1358:a1")
.POST(HttpRequest.BodyPublishers.ofString(inputJson))
.build();
StringBuilder raw = new StringBuilder();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines())
.body()
.filter(l -> l.startsWith("data:"))
.forEach(l -> raw.append(l.substring(5)));
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "experiment-desk:00328e8c:1358:a1"
req.body = JSON.dump(payload)
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
event = line[6..].strip if line.start_with?("event:")
raw << JSON.parse(line[5..])["text"] if line.start_with?("data:") && event == "delta"
end
end
end
end
<?php
$raw = "";
$event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: experiment-desk:00328e8c:1358:a1",
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:") && $event === "delta") {
$raw .= json_decode(substr($line, 5), true)["text"] ?? "";
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
var streamMsg = new HttpRequestMessage(HttpMethod.Post, BASE + "/run-stream")
{
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
};
streamMsg.Headers.Add("Idempotency-Key", "experiment-desk:00328e8c:1358:a1");
var streamRes = await http.SendAsync(streamMsg, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await streamRes.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null, line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:") && evt == "delta")
raw.Append(JsonDocument.Parse(line[5..]).RootElement.GetProperty("text").GetString());
}
9. Saving and finding past readouts
The app declares one collection, readouts, with acl_read: "owner" and
acl_write: "user". Record creation is POST
/collections/readouts/records. Every where entry must be an operator object
— the bare-value shorthand is rejected — and ordering uses a sort object;
order_by is silently ignored in favour of created_at desc.
// declared fields (queryable); the full document round-trips regardless
{ "name": "readouts", "acl_read": "owner", "acl_write": "user",
"fields": [
{ "name": "title", "type": "string" },
{ "name": "experiment", "type": "string" },
{ "name": "summary", "type": "string" },
{ "name": "decision", "type": "string" },
{ "name": "p_value", "type": "number" },
{ "name": "lift_rel", "type": "number" },
{ "name": "check_fails", "type": "number" },
{ "name": "ran_at", "type": "timestamp" }
],
"embed": ["title", "experiment", "summary", "decision"] }
# create
curl -sS -X POST "$BASE/collections/readouts/records" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"title":"Checkout - single page versus three steps","experiment":"Checkout - single page versus three steps","decision":"ship","p_value":0.0000418,"lift_rel":0.1017,"check_fails":0,"ran_at":"2026-08-08T09:00:00Z","summary":"checkout completion +10.2% at p < 0.0001; refund rate moved the wrong way but within noise; decision Ship it"}'
# exact filter - about a tenth the cost of a similarity query
curl -sS -X POST "$BASE/collections/readouts/query" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"where":{"decision":{"eq":"extend"}},"sort":{"field":"ran_at","dir":"desc"},"limit":20}'
# semantic search - 30 requests/min per IP, so debounce it
curl -sS -X POST "$BASE/collections/readouts/similar" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"text":"the checkout one where refunds moved the wrong way","limit":8}'
rec = call("/collections/readouts/records", {
"title": "Checkout - single page versus three steps",
"experiment": "Checkout - single page versus three steps",
"decision": "ship",
"p_value": 0.0000418,
"lift_rel": 0.1017,
"check_fails": 0,
"ran_at": "2026-08-08T09:00:00Z",
"summary": "checkout completion +10.2% at p < 0.0001; decision Ship it",
})
page = call("/collections/readouts/query", {
"where": {"decision": {"eq": "extend"}},
"sort": {"field": "ran_at", "dir": "desc"},
"limit": 20,
})
hits = call("/collections/readouts/similar",
{"text": "the checkout one where refunds moved the wrong way", "limit": 8})
// the SDK's similar() resolves to the record ARRAY; query() to { records }.
// Accept either shape rather than trusting one.
const recordsOf = (r) => (Array.isArray(r) ? r : (r && r.records) || []);
await call("/collections/readouts/records", {
title: "Checkout - single page versus three steps",
experiment: "Checkout - single page versus three steps",
decision: "ship",
p_value: 0.0000418,
lift_rel: 0.1017,
check_fails: 0,
ran_at: new Date().toISOString(),
summary: "checkout completion +10.2% at p < 0.0001; decision Ship it"
});
const page = await call("/collections/readouts/query", {
where: { decision: { eq: "extend" } },
sort: { field: "ran_at", dir: "desc" },
limit: 20
});
console.log(recordsOf(page).length);
rec := map[string]any{
"title": "Checkout - single page versus three steps",
"experiment": "Checkout - single page versus three steps",
"decision": "ship",
"p_value": 0.0000418,
"lift_rel": 0.1017,
"check_fails": 0,
"ran_at": time.Now().UTC().Format(time.RFC3339),
}
call("/collections/readouts/records", rec)
call("/collections/readouts/query", map[string]any{
"where": map[string]any{"decision": map[string]any{"eq": "extend"}},
"sort": map[string]any{"field": "ran_at", "dir": "desc"},
"limit": 20,
})
call("/collections/readouts/records",
"{\"title\":\"Checkout - single page versus three steps\"," +
"\"decision\":\"ship\",\"p_value\":0.0000418," +
"\"ran_at\":\"2026-08-08T09:00:00Z\"}");
call("/collections/readouts/query",
"{\"where\":{\"decision\":{\"eq\":\"extend\"}}," +
"\"sort\":{\"field\":\"ran_at\",\"dir\":\"desc\"},\"limit\":20}");
call("/collections/readouts/records", {
title: "Checkout - single page versus three steps",
experiment: "Checkout - single page versus three steps",
decision: "ship",
p_value: 0.0000418,
lift_rel: 0.1017,
check_fails: 0,
ran_at: Time.now.utc.iso8601
})
call("/collections/readouts/query", {
where: { decision: { eq: "extend" } },
sort: { field: "ran_at", dir: "desc" },
limit: 20
})
<?php
call("/collections/readouts/records", [
"title" => "Checkout - single page versus three steps",
"experiment" => "Checkout - single page versus three steps",
"decision" => "ship",
"p_value" => 0.0000418,
"lift_rel" => 0.1017,
"check_fails" => 0,
"ran_at" => gmdate("c"),
]);
call("/collections/readouts/query", [
"where" => ["decision" => ["eq" => "extend"]],
"sort" => ["field" => "ran_at", "dir" => "desc"],
"limit" => 20,
]);
await Call("/collections/readouts/records", new {
title = "Checkout - single page versus three steps",
experiment = "Checkout - single page versus three steps",
decision = "ship",
p_value = 0.0000418,
lift_rel = 0.1017,
check_fails = 0,
ran_at = DateTime.UtcNow.ToString("o")
});
await Call("/collections/readouts/query", new {
where = new { decision = new { eq = "extend" } },
sort = new { field = "ran_at", dir = "desc" },
limit = 20
});
Rules worth knowing before you design around this
- Vectors are never backfilled. Only records written after
embedwas declared are searchable, and widening the embed set later does not re-index old rows. - Indexing is asynchronous — a similarity query fired immediately after a write can lag by seconds.
- 64 KB per document. The app spends that budget in priority order and marks a record it had to trim, so a restored readout is honest about what it can no longer recompute.
- Prefer
wheretosimilarwhenever an exact match would do: it is roughly an order of magnitude cheaper and its rate limit is four times looser.
10. What this API cannot do
The free engine does not live behind an endpoint. Every statistical test, both standard errors, the
intervals, the achieved power, the required sample size, the detectable effect, the settle-it solve, the
sample-ratio chi-square, the duration and peeking checks, the Holm correction, the decision rule, the readout,
the CSV and the JSON are computed in statkit.js in the browser, with no network call and nothing
charged. If you want them server-side, the honest answer is to run that file — it is plain ES5, has no
dependencies, and exposes analyze, twoProp, welch,
requiredN, detectableEffect, srmTest, holm,
decide, renderReadout, resultsCsv, measurementJson,
reconcile and validateReadout on window.StatKit.