TAM Desk — API

Size a market from your data warehouse, your CRM or a cron job.

API tokens Open the app

Drive TAM Desk from your own code

Everything the browser computes — both builds, the triangulation, the capacity test, the unit economics, the sensitivity sweep and the memo — is deterministic and runs client-side, so the API surface here is the metered judgement pass plus the storage the app saves its sizings 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.

POST /v1/app-api/guest GET /v1/app-api/me POST /v1/app-api/estimate POST /v1/app-api/run POST /v1/app-api/run-stream POST /v1/app-api/collections/sizings/records
{ "ok": true,  "data":  { "...": "..." } }
{ "ok": false, "error": { "code": "insufficient_credits", "message": "...", "details": { } } }
CodeHTTPWhat it meansWhat to do
unauthorized401No token, or the token was minted for another app.Mint a guest token or sign in; see step 2.
insufficient_credits402The balance is below min_credits.Top up. /estimate is free, so check before you submit.
validation_error400The input JSON is not the shape the app declares.Compare against the input schema below.
rate_limited429Too many requests. Similarity search is the tightest at 30/min per IP.Back off and retry; do not tight-loop.
payload_too_large413The run input exceeded 1 MB of JSON.Clip the segment table on whole rows and keep the header.
upstream_error502/529The 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":"tam-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": "tam-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: "tam-desk" })
});
const { data } = await res.json();
const TOKEN = data.token;
body := bytes.NewBufferString(`{"slug":"tam-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\":\"tam-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: "tam-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" => "tam-desk"]),
]);
$env = json_decode(curl_exec($ch), true);
$token = $env["data"]["token"];
var guestBody = new StringContent("{\"slug\":\"tam-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 review the drivers you list, so an empty facts.drivers yields an empty review.

{
  "posture": "pre-seed | seed | series-a",
  "note": "free-text steer, <= 500 chars",
  "market_block":     "the key: value market assumptions (middle-clipped at 3000 chars)",
  "segments_excerpt": "the segment table, cut on WHOLE ROWS with the header kept (9000 chars)",
  "gtm_block":        "the go-to-market numbers (middle-clipped at 3000 chars)",
  "sources_excerpt":  "the driver: source lines (middle-clipped at 4000 chars)",
  "facts": {
    "market": "string",
    "measured_at": "YYYY-MM-DD",
    "verdict": "defensible | soft | contradictory",
    "verdict_reason": "string",
    "horizon_years": 5,
    "claimed_som": 47350000,
    "claimed_som_source": "top-down | bottom-up",
    "triangulation": [
      { "level": "TAM", "top_down": 1550000000, "bottom_up": 1473300000,
        "ratio": 1.052, "verdict": "converged | soft | contradictory | not-comparable" }
    ],
    "reconcile": { "driver": "bu_acv", "current": 20809, "required": 132844,
                   "factor": 6.38, "alt_driver": "bu_accounts", "alt_required": 74487 },
    "drivers": [
      { "id": "market_value", "label": "Total market value (top-down)", "value": 1550000000,
        "unit": "amount | share | rate/yr | years | count",
        "provenance": "sourced | stated | assumed | derived", "note": "string" }
    ],
    "segments": [
      { "id": "seg_1", "name": "string", "accounts": 6100, "acv": 62000,
        "reach": 0.72, "attach": 0.09, "tam": 378200000, "som": 24512400 }
    ],
    "capacity": { "annual_capacity": 10080000, "method": "quota | funnel",
                  "years_to_som": 4.7, "verdict": "reachable | stretched | unreachable",
                  "reps_needed": 14 },
    "unit_economics": { "ltv": 149447, "ltv_cac": 9.34, "ltv_cac_band": "healthy | thin | upside-down",
                        "payback_months": 11.7, "payback_band": "fast | acceptable | slow" },
    "growth": { "implied_cagr": 0.42, "cagr_band": "ordinary | aggressive | heroic",
                "som_share_of_sam": 0.065, "tam_future": 2612000000 },
    "sensitivity": { "pct": 25, "tied": true,
                     "rows": [ { "id": "market_value", "label": "string", "swing": 23675000 } ] },
    "checks": [ { "id": "triangulation", "level": "pass | warn | fail",
                  "label": "string", "detail": "string" } ],
    "gaps": [ "string" ],
    "warnings": [ "string" ],
    "sourcing": { "sourced": 11, "stated": 3, "assumed": 2, "lines": 11 }
  },
  "current_datetime": "2026-08-08T09:00:00Z",
  "retry_note": "optional - present only on the app's one reformat retry"
}

5. The output contract

Taken from normalizeModel() and reconcile() in sizekit.js. The model returns exactly one JSON object with no fence and no prose. Unknown verdict values normalise to needs-source, and entries with no driver_id are dropped, so a malformed review is silently thinner rather than corrupting the render.

{
  "title": "Market sizing review - North American construction project-management software",
  "verdict_note": "one or two sentences",
  "driver_reviews": [
    {
      "driver_id": "market_value",
      "verdict": "defensible | needs-source | implausible",
      "comment": "why, referring to the measured value",
      "suggested_source": "required whenever verdict is needs-source"
    }
  ],
  "reconciliation": "which build to fix and why",
  "recommended_driver": "an id from facts.drivers",
  "recommended_value": "$45,000",
  "investor_questions": [ "string" ],
  "narrative": "120-250 words",
  "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.

CheckAssertionFails when
driver_partitiondriver_reviews is a partition of facts.drivers: every id reviewed exactly once.An id is missing, reviewed twice, or is not a driver at all.
sourcing_agreementA driver whose provenance is sourced is never called needs-source.The reply contradicts the provenance ledger.
suggested_sourcesEvery needs-source verdict names a source that would settle it.Warns when some do not.
engine_gaps_excludedDrivers the engine measured as absent 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.
recommended_valuerecommended_driver is a real driver and recommended_value is re-derived against the measurement.The driver is unknown; warns when the value is not a parseable number.
narrativeThe memo paragraph exists and is long enough to carry the argument.Absent (fail) or under 80 words (warn).

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 tam-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: tam-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", "tam-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["driver_reviews"]), "driver reviews")
const job = await fetch(`${BASE}/run`, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "tam-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", "tam-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", "tam-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"] = "tam-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: tam-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", "tam-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: tam-desk:00328e8c:1358:a1" \
  --data @input.json

# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"{\"title\":\"Market sizing review"}
# 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", "tam-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": "tam-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", "tam-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", "tam-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"] = "tam-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: tam-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", "tam-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 sizings

The app declares one collection, sizings, with acl_read: "owner" and acl_write: "user". Record creation is POST /collections/sizings/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": "sizings", "acl_read": "owner", "acl_write": "user",
  "fields": [
    { "name": "title",       "type": "string"    },
    { "name": "market",      "type": "string"    },
    { "name": "summary",     "type": "string"    },
    { "name": "verdict",     "type": "string"    },
    { "name": "som_usd",     "type": "number"    },
    { "name": "tam_ratio",   "type": "number"    },
    { "name": "check_fails", "type": "number"    },
    { "name": "ran_at",      "type": "timestamp" }
  ],
  "embed": ["title", "market", "summary", "verdict"] }
# create
curl -sS -X POST "$BASE/collections/sizings/records" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"title":"Market sizing - construction PM software","market":"North American construction project-management software","verdict":"defensible","som_usd":47350000,"tam_ratio":1.052,"check_fails":0,"ran_at":"2026-08-08T09:00:00Z","summary":"top-down and bottom-up 1.05x apart (converged); SOM $47.35M; capacity reachable"}'

# exact filter - about a tenth the cost of a similarity query
curl -sS -X POST "$BASE/collections/sizings/query" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"where":{"verdict":{"eq":"contradictory"}},"sort":{"field":"ran_at","dir":"desc"},"limit":20}'

# semantic search - 30 requests/min per IP, so debounce it
curl -sS -X POST "$BASE/collections/sizings/similar" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"text":"the one where bottom-up was a thirtieth of the slide","limit":8}' 
rec = call("/collections/sizings/records", {
    "title": "Market sizing - construction PM software",
    "market": "North American construction project-management software",
    "verdict": "defensible",
    "som_usd": 47350000,
    "tam_ratio": 1.052,
    "check_fails": 0,
    "ran_at": "2026-08-08T09:00:00Z",
    "summary": "top-down and bottom-up 1.05x apart (converged); SOM $47.35M; capacity reachable",
})

page = call("/collections/sizings/query", {
    "where": {"verdict": {"eq": "contradictory"}},
    "sort": {"field": "ran_at", "dir": "desc"},
    "limit": 20,
})

hits = call("/collections/sizings/similar",
            {"text": "the one where bottom-up was a thirtieth of the slide", "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/sizings/records", {
  title: "Market sizing - construction PM software",
  market: "North American construction project-management software",
  verdict: "defensible",
  som_usd: 47350000,
  tam_ratio: 1.052,
  check_fails: 0,
  ran_at: new Date().toISOString(),
  summary: "top-down and bottom-up 1.05x apart (converged); SOM $47.35M"
});

const page = await call("/collections/sizings/query", {
  where: { verdict: { eq: "contradictory" } },
  sort: { field: "ran_at", dir: "desc" },
  limit: 20
});
console.log(recordsOf(page).length);
rec := map[string]any{
    "title":       "Market sizing - construction PM software",
    "market":      "North American construction project-management software",
    "verdict":     "defensible",
    "som_usd":     47350000,
    "tam_ratio":   1.052,
    "check_fails": 0,
    "ran_at":      time.Now().UTC().Format(time.RFC3339),
}
call("/collections/sizings/records", rec)

call("/collections/sizings/query", map[string]any{
    "where": map[string]any{"verdict": map[string]any{"eq": "contradictory"}},
    "sort":  map[string]any{"field": "ran_at", "dir": "desc"},
    "limit": 20,
})
call("/collections/sizings/records",
     "{\"title\":\"Market sizing - construction PM software\"," +
     "\"verdict\":\"defensible\",\"som_usd\":47350000," +
     "\"ran_at\":\"2026-08-08T09:00:00Z\"}");

call("/collections/sizings/query",
     "{\"where\":{\"verdict\":{\"eq\":\"contradictory\"}}," +
     "\"sort\":{\"field\":\"ran_at\",\"dir\":\"desc\"},\"limit\":20}");
call("/collections/sizings/records", {
  title:       "Market sizing - construction PM software",
  market:      "North American construction project-management software",
  verdict:     "defensible",
  som_usd:     47_350_000,
  tam_ratio:   1.052,
  check_fails: 0,
  ran_at:      Time.now.utc.iso8601
})

call("/collections/sizings/query", {
  where: { verdict: { eq: "contradictory" } },
  sort:  { field: "ran_at", dir: "desc" },
  limit: 20
})
<?php
call("/collections/sizings/records", [
    "title"       => "Market sizing - construction PM software",
    "market"      => "North American construction project-management software",
    "verdict"     => "defensible",
    "som_usd"     => 47350000,
    "tam_ratio"   => 1.052,
    "check_fails" => 0,
    "ran_at"      => gmdate("c"),
]);

call("/collections/sizings/query", [
    "where" => ["verdict" => ["eq" => "contradictory"]],
    "sort"  => ["field" => "ran_at", "dir" => "desc"],
    "limit" => 20,
]);
await Call("/collections/sizings/records", new {
    title       = "Market sizing - construction PM software",
    market      = "North American construction project-management software",
    verdict     = "defensible",
    som_usd     = 47350000,
    tam_ratio   = 1.052,
    check_fails = 0,
    ran_at      = DateTime.UtcNow.ToString("o")
});

await Call("/collections/sizings/query", new {
    where = new { verdict = new { eq = "contradictory" } },
    sort  = new { field = "ran_at", dir = "desc" },
    limit = 20
});

Rules worth knowing before you design around this

10. What this API cannot do

The free engine does not live behind an endpoint. Both builds, the triangulation, the reconciling solve, the capacity test, the unit economics, the sensitivity sweep, the memo, the CSV and the JSON are computed in sizekit.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, renderMemo, driversCsv, measurementJson, reconcile and validateMemo on window.SizeKit.