Capacity Desk — API

Build a capacity plan from your HR export, your project tracker or a Monday-morning cron job.

API tokens Open the app

Plan a period from your own roster

Send the measured facts — every person with their netted hours, role target, allocation, utilisation and peak week, the four utilisation bands, the roster rows the engine excluded with the reason for each, the week-by-week load, and the upcoming work already staffed role by role and week by week — and get back one JSON object: person_actions (exactly one reduce / backfill / absorb / hold per person the engine measured as out of band, each with a rationale), work_calls (exactly one staffing verdict per initiative), recommendations, scenarios, questions and unverified. The interesting part is that all of it is mechanically checkable, and the checker ships with the app: /capkit.js is plain ES5 with no dependencies and no network calls, so your pipeline can compute the same facts and run the same reconciliation — both partitions counted, every hours, FTE and percentage figure traced back to something measured, and any instruction to somebody already past their limit to absorb more work reported as a failure — before a number ever reaches a planning meeting. Every code step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; pick a language once and the whole page follows.

Basics

Base URL https://api.skillsafe.ai/v1/app-api, app slug capacity-desk. Every request sends Authorization: Bearer <token> and JSON bodies with Content-Type: application/json. Responses are wrapped in an envelope: {"data": …} on success, {"error": {"code", "message"}} on failure. Capacity plans are written by the gpt-terra model alias (currently gpt-5.6-terra) at a publisher markup of 1000 bps — 10%. Credits are units of 1/10 000 of a US dollar, so 10 000 credits is $1.00. /estimate, /me and /guest are free; /run and /run-stream are metered. Run input caps at 1 MB of JSON.

POST /v1/app-api/guest
GET /v1/app-api/me
POST /v1/app-api/estimate
POST /v1/app-api/run
GET /v1/app-api/jobs/{id}
POST /v1/app-api/run-stream
POST /v1/app-api/collections/plans/records
POST /v1/app-api/collections/plans/query
POST /v1/app-api/collections/plans/similar

Error codes

codestatuswhat it means
unauthorized401Missing or stale token. Mint a guest token or sign in again.
forbidden403The token belongs to a different app.
payment_required402Balance below min_credits. Call /estimate first and compare against /me.
validation_error400Malformed body. error.details names the field. A where value that is not an operator object lands here.
rate_limited429Back off. /similar is 30 req/min per IP, tighter than the other data endpoints.
not_found404Unknown job or record id.
internal5xxRetry with the SAME Idempotency-Key - it returns the original job instead of billing again.
None of the arithmetic needs an account. The whole free lane — parsing the roster, the allocations and the upcoming work, netting hours for leave, joiners and leavers, classifying role types, the overallocated / over-target / on-target / underused / excluded partition, the week-by-week load, the staffing pass, the bottlenecks, the resolved shortfall, the plan document, both CSVs and every check — is in /capkit.js. Load it in Node with a global.window = {} stub and CapKit.analyze({roster, allocations, demand, period_start, period_end, as_of, hire_lead_days, ramp_weeks, target_ic, target_manager, target_oncall}) gives you the same facts this API expects, from CapKit.factsForModel(analysis).

Step 1 · Get a token

Two ways in. /tokens.html shows the token this browser already holds and copies a shell export for it — you never need the DevTools console. Or mint a guest token from anywhere: a guest can call /me and the free /estimate, which is enough to verify the model binding, but a planning run needs a personal token so it bills your own wallet.

# Option A - take the token this browser already holds: open /tokens.html,
# press "Copy shell export", and paste the line it prints.
export SKILLSAFE_TOKEN="aut_xxxxxxxxxxxxxxxxxxxx"

# Option B - mint a guest token with no browser at all. A guest can call /me and
# the free /estimate, which is enough to verify the model binding; a planning run
# needs a personal token so it bills your own wallet.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
  -H 'Content-Type: application/json' \
  -d '{"slug":"capacity-desk"}'
# => {"data":{"token":"aut_...","subject_type":"guest","credits":0}}
import json, urllib.request

BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "capacity-desk"
TOKEN = "YOUR_TOKEN"          # from /tokens.html, or the guest() call below

def call(path, body=None, token=None, method=None):
    """The whole client. Every later step is one line on top of this."""
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data,
                                method=method or ("POST" if data else "GET"))
    req.add_header("Content-Type", "application/json")
    if token:
        req.add_header("Authorization", "Bearer " + token)
    with urllib.request.urlopen(req) as r:
        payload = json.loads(r.read().decode())
    if "error" in payload:
        raise RuntimeError(payload["error"]["code"] + ": " + payload["error"]["message"])
    return payload["data"]

def guest():
    return call("/guest", {"slug": SLUG})["token"]

if TOKEN == "YOUR_TOKEN":
    TOKEN = guest()
print(TOKEN[:8] + "...")
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "capacity-desk";
let TOKEN = "YOUR_TOKEN";       // from /tokens.html, or the guest() call below

async function call(path, body, opts = {}) {
  const res = await fetch(BASE + path, {
    method: opts.method || (body ? "POST" : "GET"),
    headers: {
      "Content-Type": "application/json",
      ...(opts.token ? { Authorization: "Bearer " + opts.token } : {}),
      ...(opts.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : {})
    },
    body: body ? JSON.stringify(body) : undefined
  });
  const payload = await res.json();
  if (payload.error) throw new Error(payload.error.code + ": " + payload.error.message);
  return payload.data;
}

const guest = () => call("/guest", { slug: SLUG }).then((d) => d.token);

if (TOKEN === "YOUR_TOKEN") TOKEN = await guest();
console.log(TOKEN.slice(0, 8) + "...");
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
)

const base = "https://api.skillsafe.ai/v1/app-api"
const slug = "capacity-desk"

var token = "YOUR_TOKEN" // from /tokens.html, or guest() below

type envelope struct {
	Data  json.RawMessage `json:"data"`
	Error *struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"error"`
}

func call(path string, body any, tok string, idem string) (json.RawMessage, error) {
	var rdr io.Reader
	method := "GET"
	if body != nil {
		b, _ := json.Marshal(body)
		rdr = bytes.NewReader(b)
		method = "POST"
	}
	req, _ := http.NewRequest(method, base+path, rdr)
	req.Header.Set("Content-Type", "application/json")
	if tok != "" {
		req.Header.Set("Authorization", "Bearer "+tok)
	}
	if idem != "" {
		req.Header.Set("Idempotency-Key", idem)
	}
	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.Error != nil {
		return nil, errors.New(env.Error.Code + ": " + env.Error.Message)
	}
	return env.Data, nil
}

func guest() (string, error) {
	data, err := call("/guest", map[string]string{"slug": slug}, "", "")
	if err != nil {
		return "", err
	}
	var out struct{ Token string `json:"token"` }
	err = json.Unmarshal(data, &out)
	return out.Token, err
}

func main() {
	if token == "YOUR_TOKEN" {
		t, err := guest()
		if err != nil {
			panic(err)
		}
		token = t
	}
	fmt.Println(token[:8] + "...")
}
import java.net.URI;
import java.net.http.*;
import java.util.Optional;

public class DayOne {
  static final String BASE = "https://api.skillsafe.ai/v1/app-api";
  static final String SLUG = "capacity-desk";
  static String token = "YOUR_TOKEN";   // from /tokens.html, or guest() below

  static final HttpClient HTTP = HttpClient.newHttpClient();

  /** Returns the raw JSON body. Any JSON library will do for parsing;
   *  the envelope is {"data": ...} on success, {"error": {...}} on failure. */
  static String call(String path, String body, String tok, String idem) throws Exception {
    HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
        .header("Content-Type", "application/json");
    if (tok != null) b.header("Authorization", "Bearer " + tok);
    if (idem != null) b.header("Idempotency-Key", idem);
    b = body == null ? b.GET() : b.POST(HttpRequest.BodyPublishers.ofString(body));
    HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
    if (res.body().contains("\"error\"")) throw new RuntimeException(res.body());
    return res.body();
  }

  static String guest() throws Exception {
    String out = call("/guest", "{\"slug\":\"" + SLUG + "\"}", null, null);
    int at = out.indexOf("\"token\":\"") + 9;
    return out.substring(at, out.indexOf('"', at));
  }

  public static void main(String[] args) throws Exception {
    if (token.equals("YOUR_TOKEN")) token = guest();
    System.out.println(token.substring(0, 8) + "...");
  }
}
require "json"
require "net/http"

BASE = URI("https://api.skillsafe.ai/v1/app-api")
SLUG = "capacity-desk"
TOKEN = "YOUR_TOKEN"          # from /tokens.html, or guest below

def call(path, body = nil, token: nil, idem: nil)
  uri = URI(BASE.to_s + path)
  req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
  req["Content-Type"] = "application/json"
  req["Authorization"] = "Bearer #{token}" if token
  req["Idempotency-Key"] = idem if idem
  req.body = JSON.dump(body) if body
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise "#{payload["error"]["code"]}: #{payload["error"]["message"]}" if payload["error"]
  payload["data"]
end

def guest
  call("/guest", { "slug" => SLUG })["token"]
end

token = TOKEN == "YOUR_TOKEN" ? guest : TOKEN
puts token[0, 8] + "..."
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "capacity-desk";
$token = "YOUR_TOKEN";        // from /tokens.html, or guest() below

function call(string $path, ?array $body = null, ?string $token = null, ?string $idem = null) {
    $headers = ["Content-Type: application/json"];
    if ($token) $headers[] = "Authorization: Bearer $token";
    if ($idem)  $headers[] = "Idempotency-Key: $idem";
    $ch = curl_init(BASE . $path);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_CUSTOMREQUEST => $body === null ? "GET" : "POST",
    ]);
    if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    $payload = json_decode(curl_exec($ch), true);
    curl_close($ch);
    if (isset($payload["error"])) {
        throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
    }
    return $payload["data"];
}

function guest(): string {
    return call("/guest", ["slug" => SLUG])["token"];
}

if ($token === "YOUR_TOKEN") $token = guest();
echo substr($token, 0, 8), "...\n";
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "capacity-desk";
var token = "YOUR_TOKEN";     // from /tokens.html, or Guest() below

var http = new HttpClient();

async Task<JsonElement> Call(string path, object? body = null, string? tok = null, string? idem = null)
{
    var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, Base + path);
    if (body is not null)
        req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
    if (tok is not null) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tok);
    if (idem is not null) req.Headers.Add("Idempotency-Key", idem);
    var res = await http.SendAsync(req);
    using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
    if (doc.RootElement.TryGetProperty("error", out var err))
        throw new Exception(err.GetProperty("code").GetString() + ": " + err.GetProperty("message").GetString());
    return doc.RootElement.GetProperty("data").Clone();
}

async Task<string> Guest() => (await Call("/guest", new { slug = Slug })).GetProperty("token").GetString()!;

if (token == "YOUR_TOKEN") token = await Guest();
Console.WriteLine(token[..8] + "...");

Step 2 · Check the session and the balance

/me is free and tells you whether the token is a personal one or a guest, and what the wallet holds. Compare it against min_credits from step 3 before submitting: a 402 after the fact is a failure of your client, not of the user.

curl -s https://api.skillsafe.ai/v1/app-api/me \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# => {"data":{"subject_type":"user","credits":48210,"app_slug":"capacity-desk"}}
# credits are 1/10 000 of a US dollar, so 48210 is $4.8210.
me = call("/me", token=TOKEN)
print(me["subject_type"], me["credits"], "credits =",
      "${:.4f}".format(me["credits"] / 10000))
const me = await call("/me", null, { token: TOKEN });
console.log(me.subject_type, me.credits, "credits = $" + (me.credits / 10000).toFixed(4));
data, err := call("/me", nil, token, "")
if err != nil {
	panic(err)
}
var me struct {
	SubjectType string `json:"subject_type"`
	Credits     int    `json:"credits"`
}
json.Unmarshal(data, &me)
fmt.Printf("%s %d credits = $%.4f\n", me.SubjectType, me.Credits, float64(me.Credits)/10000)
String me = call("/me", null, token, null);
System.out.println(me);   // {"data":{"subject_type":"user","credits":48210,...}}
me = call("/me", token: token)
puts "#{me["subject_type"]} #{me["credits"]} credits = $#{"%.4f" % (me["credits"] / 10000.0)}"
$me = call("/me", null, $token);
printf("%s %d credits = $%.4f\n", $me["subject_type"], $me["credits"], $me["credits"] / 10000);
var me = await Call("/me", null, token);
var credits = me.GetProperty("credits").GetInt32();
Console.WriteLine($"{me.GetProperty("subject_type").GetString()} {credits} credits = ${credits / 10000.0:F4}");

Step 3 · Estimate, and assert the model binding

/estimate costs nothing and creates no job. It returns model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled. hold_credits is reserved against the full output cap; the run settles at charged_credits, usually far lower. Because it is free, it is also the cheapest possible assertion in CI that this app is still bound to the model and markup you expect.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -d @input.json
# => {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
#             "hold_credits":3120,"min_credits":260,"sponsor_enabled":false}}
#
# hold_credits is RESERVED, not charged: it prices the full output cap. The run
# settles at charged_credits, usually far lower. /estimate is free and creates
# no job, so it is also the cheapest way to assert the model binding in CI.
est = call("/estimate", INPUT, token=TOKEN)
assert est["model"] == "gpt-5.6-terra" and est["model_alias"] == "gpt-terra"
assert est["markup_bps"] == 1000
if me["credits"] < est["min_credits"]:
    raise SystemExit("balance below the model minimum - top up before running")
print("reserved up to", est["hold_credits"], "credits; only what the run uses is charged")
const est = await call("/estimate", INPUT, { token: TOKEN });
if (est.model_alias !== "gpt-terra" || est.markup_bps !== 1000) throw new Error("unexpected binding");
if (me.credits < est.min_credits) throw new Error("balance below the model minimum");
console.log("reserved up to", est.hold_credits, "- charged is usually much less");
data, err = call("/estimate", input, token, "")
if err != nil {
	panic(err)
}
var est struct {
	Model       string `json:"model"`
	ModelAlias  string `json:"model_alias"`
	MarkupBps   int    `json:"markup_bps"`
	HoldCredits int    `json:"hold_credits"`
	MinCredits  int    `json:"min_credits"`
}
json.Unmarshal(data, &est)
if est.ModelAlias != "gpt-terra" || est.MarkupBps != 1000 {
	panic("unexpected model binding")
}
fmt.Println("reserved up to", est.HoldCredits, "credits")
String est = call("/estimate", INPUT_JSON, token, null);
if (!est.contains("\"model_alias\":\"gpt-terra\"")) throw new RuntimeException("unexpected binding");
System.out.println(est);
est = call("/estimate", INPUT, token: token)
raise "unexpected binding" unless est["model_alias"] == "gpt-terra" && est["markup_bps"] == 1000
raise "balance below the model minimum" if me["credits"] < est["min_credits"]
puts "reserved up to #{est["hold_credits"]} credits"
$est = call("/estimate", $input, $token);
if ($est["model_alias"] !== "gpt-terra" || $est["markup_bps"] !== 1000) {
    throw new RuntimeException("unexpected model binding");
}
if ($me["credits"] < $est["min_credits"]) {
    throw new RuntimeException("balance below the model minimum");
}
echo "reserved up to ", $est["hold_credits"], " credits\n";
var est = await Call("/estimate", input, token);
if (est.GetProperty("model_alias").GetString() != "gpt-terra") throw new Exception("unexpected binding");
if (credits < est.GetProperty("min_credits").GetInt32()) throw new Exception("balance below minimum");
Console.WriteLine($"reserved up to {est.GetProperty("hold_credits").GetInt32()} credits");

The input — what facts has to carry

Everything under facts is measured, not asked for. The model is instructed never to recompute a figure: every total, every person's netted hours and utilisation, every band and the whole shortfall resolution are already here, and anything it writes is traced back to them. Compute this object with CapKit.factsForModel(CapKit.analyze(...)) from /capkit.js, or assemble it yourself in the shape below.

{
  "period_label": "Q1 FY26 - Payments Platform",
  "note": "free-text steer (may be empty)",
  "facts": {
    "as_of": "2026-01-05",
    "period": { "label": "Q1 FY26 - Payments Platform", "start": "2026-01-05",
                "end": "2026-03-31", "business_days": 62, "weeks": 13 },
    "utilisation_targets": { "ic": 0.8, "manager": 0.7, "oncall": 0.6 },
    "totals": { "head_count": 6, "gross_hours": 2397, "target_hours": 1876,
                "allocated_hours": 1004, "headroom_hours": 872, "over_hours": 0,
                "utilisation": 0.42, "peak_week_utilisation": 0.5, "crunch_week_count": 0,
                "demand_hours": 512, "net_headroom_hours": 360, "unmet_hours": 115,
                "cushion_hours": 465, "person_period_hours": 496 },
    "note_on_derivation": "headroom_hours, utilisation and any headcount derived from a shortfall are ONE measurement restated ... net_headroom_hours ignores role and timing and is only a ceiling; unmet_hours is what the staffing pass could not place.",
    "overallocated": [], 
    "over_target": [{ "id": "P1", "name": "Rita Voss", "role": "Staff Backend Engineer",
                      "role_type": "ic", "skills": "Go / Postgres", "weekly_hours": 40,
                      "working_days": 59, "leave_days": 3, "gross_hours": 472,
                      "target_utilisation": 0.8, "target_hours": 378, "allocated_hours": 446,
                      "utilisation": 0.95, "headroom_hours": -68, "peak_week": "2026-01-05",
                      "peak_week_utilisation": 0.95, "projects": ["Billing rewrite"],
                      "bucket": "over_target", "flags": [] }],
    "on_target": [], "underused": [],
    "excluded_people": [{ "id": "P7", "name": "Lou Bergstrom",
                          "reason": "weekly hours \"TBD\" is not a positive number" }],
    "upcoming_work": [{ "id": "W1", "project": "Payments v2", "role_needed": "Backend Engineer",
                        "start": "2026-02-02", "end": "2026-03-31", "required_hours": 202,
                        "concurrent_fte": 0.6, "staffed_hours": 202, "shortfall_hours": 0,
                        "priority": "must", "coverage": "covered",
                        "matching_people": ["P1", "P2", "P6"], "drawn_from": ["P2", "P6"] }],
    "excluded_work": [{ "id": "W4", "project": "Brand refresh",
                        "reason": "runs entirely outside the period" }],
    "excluded_allocations": [{ "id": "A6", "person": "Sofia Marchetti",
                               "project": "Referral programme",
                               "reason": "\"Sofia Marchetti\" is not on the roster" }],
    "bottlenecks": [{ "role_needed": "SRE", "unmet_hours": 112, "matching_people": 1,
                      "their_headroom_hours": 41, "initiatives": ["W3"] }],
    "role_capacity": [{ "role": "Backend Engineer", "people": 2, "gross_hours": 592,
                        "allocated_hours": 426, "headroom_hours": 48, "demand_hours": 202,
                        "net_hours": -154 }],
    "crunch_weeks": [{ "week_of": "2026-02-09", "capacity_hours": 192,
                       "load_hours": 214, "utilisation": 1.11 }],
    "hire_reality": { "earliest_start": "2026-03-06", "hours_before_period_end": 36,
                      "can_help_this_period": true },
    "gap_solution": { "state": "covered | closable | unreachable | overallocated-only | no-demand",
                      "text": "the resolved shortfall, in one sentence",
                      "shortfall_hours": 115, "cushion_hours": 0,
                      "residual_after_deferrals": 0,
                      "deferral_candidates": ["W3", "W2"] },
    "data_quality": [{ "level": "pass | warn | fail | info", "id": "no_pto", "text": "..." }],
    "action_ids": ["P1", "P3", "P4", "P6"],
    "work_ids": ["W1", "W2", "W3"]
  },
  "roster_excerpt": "the roster as pasted, cut on whole rows with the header kept",
  "allocations_excerpt": "who is on what",
  "demand_excerpt": "the work that is coming",
  "current_datetime": "2026-01-05T09:00:00Z",
  "retry_note": "optional - only on a reformat retry, see below"
}
Four fields do quiet work. as_of is what the hire lead time counts from, so it is what makes "a hire cannot start before this period ends" a fact rather than an opinion — pin it in tests and the output stops drifting. unmet_hours is the staffed shortfall and net_headroom_hours is only a ceiling: the two disagree exactly when role or timing binds, which is the case worth catching. And action_ids and work_ids are the two partitions the model must cover exactly once each; they deliberately exclude anything the engine itself measured as absent, so a person with unreadable hours or an initiative outside the period is not in them and the model is never blamed for a gap the free lane already found and named.

retry_note is the one field you would not normally send. The app adds it when a reply could not be parsed, resubmitting the same input with the parse error and a restatement of the contract; the system prompt defines it as an instruction about formatting only, so the model must not analyse it or mention the retry in its answer. Send the run with the same Idempotency-Key if you build the same retry yourself, or you will be billed twice for one plan.

Step 4 · Run and poll

Always send an Idempotency-Key: a content hash of the input plus an attempt counter. A timeout, a dropped connection or a 5xx retried with the same key returns the original job rather than billing a second one. output.output is the capacity-plan JSON as a string - parse it, then check it. truncated: true means a low balance cut the reply short; render it as partial rather than presenting it as a whole capacity plan.

# The Idempotency-Key is a content hash of the input plus an attempt counter.
# Reusing it after a timeout or a 5xx returns the ORIGINAL job instead of
# billing a second one.
JOB=$(curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: capacity-desk:9f2ac41b:a1' \
  -d @input.json | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll to a terminal state.
until curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" | tee /tmp/job.json \
  | grep -q '"status":"\(succeeded\|failed\)"'; do sleep 2; done

# output.output is the capacity-plan JSON as a string. Parse it, then check it.
python3 -c 'import json;d=json.load(open("/tmp/job.json"))["data"];\
print(json.loads(d["output"]["output"])["title"]);\
print("charged", d.get("charged_credits"), "truncated", d.get("truncated"))'
import time, hashlib

key = "capacity-desk:" + hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:8] + ":a1"
job_id = call("/run", INPUT, token=TOKEN)["job_id"]   # send the key as Idempotency-Key

while True:
    job = call("/jobs/" + job_id, token=TOKEN)
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(2)

plan = json.loads(job["output"]["output"])         # the JSON object SKILL.md defines
if job.get("truncated"):
    print("reply cut short by the available balance - treat it as partial")
print(plan["title"], "-", len(plan["person_actions"]), "moves,",
      len(plan["work_calls"]), "staffing verdicts")
print("charged", job.get("charged_credits"), "credits")
const key = "capacity-desk:" + hash(JSON.stringify(INPUT)) + ":a1";  // any stable hash
const { job_id } = await call("/run", INPUT, { token: TOKEN, idempotencyKey: key });

let job;
for (;;) {
  job = await call("/jobs/" + job_id, null, { token: TOKEN });
  if (job.status === "succeeded" || job.status === "failed") break;
  await new Promise((r) => setTimeout(r, 2000));
}

const fc = JSON.parse(job.output.output);
if (job.truncated) console.warn("reply cut short by the balance - partial");
console.log(fc.title, fc.person_actions.length, "moves,", fc.work_calls.length, "verdicts");
data, err = call("/run", input, token, "capacity-desk:9f2ac41b:a1")
if err != nil {
	panic(err)
}
var started struct{ JobID string `json:"job_id"` }
json.Unmarshal(data, &started)

var job struct {
	Status         string `json:"status"`
	Truncated      bool   `json:"truncated"`
	ChargedCredits int    `json:"charged_credits"`
	Output         struct{ Output string `json:"output"` } `json:"output"`
}
for {
	data, err = call("/jobs/"+started.JobID, nil, token, "")
	if err != nil {
		panic(err)
	}
	json.Unmarshal(data, &job)
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(2 * time.Second)
}
var fc map[string]any
json.Unmarshal([]byte(job.Output.Output), &fc)
fmt.Println(fc["title"], "charged", job.ChargedCredits, "truncated", job.Truncated)
String started = call("/run", INPUT_JSON, token, "capacity-desk:9f2ac41b:a1");
String jobId = started.split("\"job_id\":\"")[1].split("\"")[0];

String job;
while (true) {
  job = call("/jobs/" + jobId, null, token, null);
  if (job.contains("\"status\":\"succeeded\"") || job.contains("\"status\":\"failed\"")) break;
  Thread.sleep(2000);
}
System.out.println(job);   // data.output.output holds the capacity-plan JSON as a string
started = call("/run", INPUT, token: token, idem: "capacity-desk:9f2ac41b:a1")
job = nil
loop do
  job = call("/jobs/#{started["job_id"]}", token: token)
  break if %w[succeeded failed].include?(job["status"])
  sleep 2
end

fc = JSON.parse(job["output"]["output"])
warn "reply cut short by the balance - partial" if job["truncated"]
puts "#{fc["title"]}: #{fc["person_actions"].length} moves, charged #{job["charged_credits"]}"
$started = call("/run", $input, $token, "capacity-desk:9f2ac41b:a1");
do {
    $job = call("/jobs/" . $started["job_id"], null, $token);
    if (in_array($job["status"], ["succeeded", "failed"], true)) break;
    sleep(2);
} while (true);

$fc = json_decode($job["output"]["output"], true);
if (!empty($job["truncated"])) fwrite(STDERR, "reply cut short - partial\n");
printf("%s: %d moves, charged %d\n", $fc["title"], count($fc["person_actions"]), $job["charged_credits"]);
var started = await Call("/run", input, token, "capacity-desk:9f2ac41b:a1");
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
while (true)
{
    job = await Call("/jobs/" + jobId, null, token);
    var status = job.GetProperty("status").GetString();
    if (status is "succeeded" or "failed") break;
    await Task.Delay(2000);
}

using var fc = JsonDocument.Parse(job.GetProperty("output").GetProperty("output").GetString()!);
Console.WriteLine(fc.RootElement.GetProperty("title").GetString());

The output contract

Exactly one JSON object, no prose and no code fences. Two of these fields are partitions, and that is the part worth wiring into your own tests: person_actions carries exactly one entry per id in facts.action_ids, and work_calls exactly one per id in facts.work_ids. Count them - an id appearing twice is as much a failure as one missing, and asserting presence alone will not catch it.

{
  "title": "Q1 FY26 capacity plan - Payments Platform",
  "plan_summary": "Two to four sentences: whether the period fits, what it rests on, and the change with the most leverage.",
  "person_actions": [
    { "person_id": "P1", "move": "reduce | backfill | absorb | hold",
      "rationale": "one sentence citing the utilisation, the peak week, the project count, a flag or the leave" }
  ],
  "work_calls": [
    { "work_id": "W1", "verdict": "staffed | partially-staffed | unstaffed | defer",
      "staffing": "who actually does it, drawn from matching_people",
      "rationale": "one sentence grounded in the measurement" }
  ],
  "recommendations": ["ordered steps that follow from gap_solution, most leverage first"],
  "scenarios": [
    { "scenario": "Do nothing", "outcome": "what happens, in the engine's own figures" }
  ],
  "questions": ["three to six questions, each aimed at a person, an initiative or a measured weakness"],
  "unverified": ["anything that could not be traced to facts - ideally empty"]
}

What the app checks, and what you should check too

checkhow it fails
person_actions partitionA person out of band with no move, a person named twice, a move for somebody the engine excluded (reported separately as off-contract), or a person_id that is not an id at all. Four distinct failures, four distinct findings.
work_calls partitionA measurable initiative with no verdict, a verdict twice, a verdict for an initiative the engine excluded, or an id that does not exist. When work_ids is empty, any work call at all is a failure.
vocabularyA move outside reduce / backfill / absorb / hold, or a verdict outside staffed / partially-staffed / unstaffed / defer.
absorb sanityTelling somebody the engine measured as overallocated or over target to absorb more work is a hard failure, named with their measured utilisation. Telling an underused person to reduce is recorded as a judgement, not a failure.
absorb arithmeticThe headroom of everyone asked to absorb is summed and printed against the measured need, so "spread it around" is answered with a number.
figure groundingEvery hours, FTE and percentage token in the narrative is parsed and matched, within 2%, against a total in facts.totals, a person's own hours or utilisation, an initiative's required or shortfall hours, a bottleneck figure or a number in gap_solution. Anything that matches nothing is named as an invented figure. The unit must be adjacent to the digits and must not be the first letters of the next word, so 40 hires is not read as forty hours.
hire lead timeIf hire_reality.can_help_this_period is false and the reply recommends hiring without saying it cannot help this period, that fails.
honesty on an unreachable gapAn unreachable shortfall where every initiative is called staffed or partially staffed, with nothing deferred or declared unstaffed, fails.
scenariosNo scenarios at all fails; scenarios without a do-nothing row warn, because there is then nothing to compare the proposed changes against.
document checksRun on the rendered plan: a missing section, a stated allocated total that does not equal the sum of its own per-person allocated column, a gross capacity that does not add up, utilisation quoted without saying it is headroom restated, and an unreachable shortfall the document does not admit to.
the export guardBefore any download the rendered plan is read back and every measured person id is looked up. A person missing, listed under two bands, or rendered under a band they were not measured into refuses the export rather than warning about it - and so does a lost initiative.
One exemption is deliberate and worth copying: roster rows the engine removed - a duplicate name, unreadable hours, somebody who joins after the period ends - are excluded from the person_actions partition entirely. They are reported in facts.excluded_people with the reason for each, so an operator can see them, but the model is not asked to make a call about a person whose hours could not be read, and is therefore never penalised for a data gap the free lane already measured. The two sets are disjoint by construction, so a genuinely missing move cannot hide inside the exemption.

Step 5 · Stream it instead

/run-stream is the same call over server-sent events, which is what the app itself uses so the progress card can advance on real signals. The frame name arrives on the event: line - there is no type field inside the payload. Frames are job, delta, done and error. Accumulate the delta text and prefer done.output.output when it arrives; if the stream dies mid-flight, what you accumulated is usually still worth parsing.

# SSE. The frame name arrives on the `event:` line - there is no `type` field
# inside the payload. Frames: job (job_id), delta (text chunks), done (the
# terminal job with charged_credits), error.
curl -N -s -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: capacity-desk:9f2ac41b:a1' \
  -d @input.json
# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"{\"title\":\"Q1 FY26 capacity plan"}
# event: done
# data: {"status":"succeeded","charged_credits":1180,"output":{"output":"{...}"}}
req = urllib.request.Request(BASE + "/run-stream", data=json.dumps(INPUT).encode(),
                             method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", "capacity-desk:9f2ac41b: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[7:]                     # the frame name is on this line
        elif line.startswith("data: "):
            payload = json.loads(line[6:])
            if event == "delta":
                raw += payload.get("text", "")   # stream the plan as it is written
            elif event == "done":
                raw = payload["output"]["output"] or raw
                print("charged", payload.get("charged_credits"))

plan = json.loads(raw)
const res = await fetch(BASE + "/run-stream", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: "Bearer " + TOKEN,
    "Idempotency-Key": "capacity-desk:9f2ac41b:a1"
  },
  body: JSON.stringify(INPUT)
});

const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null;
for (;;) {
  const { value, done } = 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(7).trim();
    else if (line.startsWith("data: ")) {
      const payload = JSON.parse(line.slice(6));
      if (event === "delta") raw += payload.text || "";
      else if (event === "done") raw = payload.output?.output || raw;
    }
  }
}
const fc = JSON.parse(raw);
body, _ := json.Marshal(input)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "capacity-desk:9f2ac41b:a1")
res, err := http.DefaultClient.Do(req)
if err != nil {
	panic(err)
}
defer res.Body.Close()

sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
var raw, event string
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event: "):
		event = strings.TrimSpace(line[7:])
	case strings.HasPrefix(line, "data: "):
		var p struct {
			Text   string `json:"text"`
			Output struct{ Output string `json:"output"` } `json:"output"`
		}
		json.Unmarshal([]byte(line[6:]), &p)
		if event == "delta" {
			raw += p.Text
		} else if event == "done" && p.Output.Output != "" {
			raw = p.Output.Output
		}
	}
}
fmt.Println(len(raw), "characters of capacity-plan JSON")
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer " + token)
    .header("Idempotency-Key", "capacity-desk:9f2ac41b:a1")
    .POST(HttpRequest.BodyPublishers.ofString(INPUT_JSON))
    .build();

StringBuilder raw = new StringBuilder();
String[] event = { null };
HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
  if (line.startsWith("event: ")) event[0] = line.substring(7).trim();
  else if (line.startsWith("data: ") && "delta".equals(event[0])) {
    String d = line.substring(6);
    int at = d.indexOf("\"text\":\"");
    if (at >= 0) raw.append(d, at + 8, d.lastIndexOf('"'));
  }
});
System.out.println(raw.length() + " characters streamed");
uri = URI(BASE.to_s + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}"
req["Idempotency-Key"] = "capacity-desk:9f2ac41b:a1"
req.body = JSON.dump(INPUT)

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|
        line = line.chomp
        if line.start_with?("event: ")
          event = line[7..].strip
        elsif line.start_with?("data: ")
          payload = JSON.parse(line[6..])
          raw += payload["text"].to_s if event == "delta"
          raw = payload.dig("output", "output") || raw if event == "done"
        end
      end
    end
  end
end
fc = JSON.parse(raw)
$raw = "";
$event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode($input),
    CURLOPT_HTTPHEADER => [
        "Content-Type: application/json",
        "Authorization: Bearer $token",
        "Idempotency-Key: capacity-desk:9f2ac41b:a1",
    ],
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
        foreach (explode("\n", $chunk) as $line) {
            $line = rtrim($line);
            if (str_starts_with($line, "event: ")) {
                $event = trim(substr($line, 7));
            } elseif (str_starts_with($line, "data: ")) {
                $payload = json_decode(substr($line, 6), true);
                if ($event === "delta") $raw .= $payload["text"] ?? "";
                if ($event === "done")  $raw = $payload["output"]["output"] ?? $raw;
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);
$fc = json_decode($raw, true);
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream")
{
    Content = new StringContent(JsonSerializer.Serialize(input), Encoding.UTF8, "application/json")
};
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Headers.Add("Idempotency-Key", "capacity-desk:9f2ac41b:a1");

using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null;
while (await reader.ReadLineAsync() is string line)
{
    if (line.StartsWith("event: ")) evt = line[7..].Trim();
    else if (line.StartsWith("data: "))
    {
        using var d = JsonDocument.Parse(line[6..]);
        if (evt == "delta" && d.RootElement.TryGetProperty("text", out var t))
            raw.Append(t.GetString());
        else if (evt == "done" && d.RootElement.TryGetProperty("output", out var o))
            raw.Clear().Append(o.GetProperty("output").GetString());
    }
}
using var fc = JsonDocument.Parse(raw.ToString());

Step 6 · Store and search past plans

The app declares one collection, plans, with acl_read: owner and acl_write: user - records belong to the calling identity. Declared fields are title, period, team, summary and outcome (strings), head_count, utilisation, unmet_hours, demand_hours and check_fails (numbers) and ran_at (timestamp); the rest of the document, including the whole plan markdown, round-trips intact but is not filterable. The embed set is title, period, team and summary - the summary is the one that earns its place, because "the quarter that hung on one SRE" lives in the shortfall resolution, not in the title.

# Exact filter: every plan whose work did not fit, newest first. `where` values
# must be operator OBJECTS - a bare value is rejected. Ordering is the `sort`
# object; `order_by` is silently ignored.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/plans/query \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"where":{"outcome":{"eq":"unreachable"}},
       "sort":{"field":"ran_at","dir":"desc"},"limit":20}'

# Create a record. Note the path: /records, not the collection root.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/plans/records \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"title":"Q1 FY26 capacity plan - Payments Platform","period":"Q1 FY26 - Payments Platform",
       "team":"Rita Voss, Sam Iyer, Nina Park - Staff Backend Engineer, Engineering Manager","summary":"6 people at 42%, peak week 50%; 512 h of upcoming work, 115 h unstaffed; clears by deferring 2 initiatives",
       "outcome":"closable","utilisation":0.42,"head_count":6,"unmet_hours":115,"check_fails":0,
       "ran_at":"2026-01-05T09:00:00Z","doc_md":"# Capacity plan..."}'

# Semantic search over title, period, team and summary. 30 req/min per IP and
# about ten times the cost of the filter above - use `where` when an exact match
# would do.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/plans/similar \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"text":"the quarter that hung on one SRE","limit":8}'
# Exact filter - cheap, and the right tool whenever the question has an exact answer.
page = call("/collections/plans/query", {
    "where": {"outcome": {"eq": "short"}},
    "sort": {"field": "ran_at", "dir": "desc"},
    "limit": 20
}, token=TOKEN)
for rec in page["records"]:
    print(rec["record_id"], rec["doc"]["title"], rec["doc"]["likely"])

# Write one. The path ends in /records.
call("/collections/plans/records", {
    "title": "Q1 FY26 capacity plan - Payments Platform",
    "period": "Q1 FY26 - Payments West",
    "team": "Rita Voss, Sam Iyer",
    "summary": "6 people at 42%, peak week 50%; 512 h of upcoming work, 115 h unstaffed",
    "outcome": "at-risk", "likely": 802800,
    "head_count": 9, "check_fails": 0,
    "ran_at": "2026-03-02T09:00:00Z", "doc_md": plan_markdown
}, token=TOKEN)

# Semantic search. Returns records with a `score`; debounce it, 30/min per IP.
hits = call("/collections/plans/similar",
            {"text": "the quarter that hung on one SRE", "limit": 8}, token=TOKEN)
for rec in (hits if isinstance(hits, list) else hits["records"]):
    print(round(rec["score"], 3), rec["doc"]["summary"])
const page = await call("/collections/plans/query", {
  where: { outcome: { eq: "unreachable" } },
  sort: { field: "ran_at", dir: "desc" },
  limit: 20
}, { token: TOKEN });

await call("/collections/plans/records", {
  title: "Q1 FY26 capacity plan - Payments Platform",
  period: "Q1 FY26 - Payments West",
  team: "Rita Voss, Sam Iyer",
  summary: "6 people at 42%, peak week 50%; 512 h of upcoming work, 115 h unstaffed",
  outcome: "closable", utilisation: 0.42,
  head_count: 9, check_fails: 0,
  ran_at: new Date().toISOString(), doc_md: planMarkdown
}, { token: TOKEN });

// similar() over the SDK resolves to the record ARRAY; the REST call returns
// {records}. Accept either shape rather than trusting one.
const hits = await call("/collections/plans/similar",
  { text: "the quarter that hung on one SRE", limit: 8 }, { token: TOKEN });
for (const rec of Array.isArray(hits) ? hits : hits.records) {
  console.log(rec.score.toFixed(3), rec.doc.summary);
}
query := map[string]any{
	"where": map[string]any{"outcome": map[string]any{"eq": "short"}},
	"sort":  map[string]any{"field": "ran_at", "dir": "desc"},
	"limit": 20,
}
data, err = call("/collections/plans/query", query, token, "")
if err != nil {
	panic(err)
}
var page struct {
	Records []struct {
		RecordID string         `json:"record_id"`
		Doc      map[string]any `json:"doc"`
	} `json:"records"`
}
json.Unmarshal(data, &page)
for _, r := range page.Records {
	fmt.Println(r.RecordID, r.Doc["title"])
}

// Semantic search - note the /similar path and the 30 req/min per-IP limit.
data, _ = call("/collections/plans/similar",
	map[string]any{"text": "the quarter that hung on one SRE", "limit": 8}, token, "")
fmt.Println(string(data))
String body = "{\"where\":{\"outcome\":{\"eq\":\"unreachable\"}},"
    + "\"sort\":{\"field\":\"ran_at\",\"dir\":\"desc\"},\"limit\":20}";
System.out.println(call("/collections/plans/query", body, token, null));

// Create: the path ends in /records, not at the collection root.
String rec = "{\"title\":\"Q1 FY26 capacity plan - Payments Platform\","
    + "\"period\":\"Q1 FY26 - Payments West\",\"team\":\"Rita Voss, Sam Iyer\","
    + "\"summary\":\"9 deals, $492,800 weighted; $97,200 short of $900,000; closable with 2 upside deals\","
    + "\"outcome\":\"closable\",\"utilisation\":0.42,\"head_count\":6,"
    + "\"check_fails\":0,\"ran_at\":\"2026-08-17T09:00:00Z\"}";
call("/collections/plans/records", rec, token, null);

System.out.println(call("/collections/plans/similar",
    "{\"text\":\"the quarter that hung on one SRE\",\"limit\":8}", token, null));
page = call("/collections/plans/query", {
  "where" => { "outcome" => { "eq" => "short" } },
  "sort" => { "field" => "ran_at", "dir" => "desc" },
  "limit" => 20
}, token: token)
page["records"].each { |r| puts "#{r["record_id"]} #{r["doc"]["title"]}" }

call("/collections/plans/records", {
  "title" => "Q1 FY26 capacity plan - Payments Platform",
  "period" => "Q1 FY26 - Payments West",
  "team" => "Rita Voss, Sam Iyer",
  "summary" => "6 people at 42%, peak week 50%; 512 h of upcoming work, 115 h unstaffed",
  "outcome" => "at-risk", "likely" => 802800,
  "head_count" => 34, "check_fails" => 0,
  "ran_at" => Time.now.utc.iso8601, "doc_md" => plan_markdown
}, token: token)

hits = call("/collections/plans/similar",
            { "text" => "the quarter that hung on one SRE", "limit" => 8 }, token: token)
records = hits.is_a?(Array) ? hits : hits["records"]
records.each { |r| puts "#{r["score"].round(3)} #{r["doc"]["summary"]}" }
$page = call("/collections/plans/query", [
    "where" => ["outcome" => ["eq" => "short"]],
    "sort" => ["field" => "ran_at", "dir" => "desc"],
    "limit" => 20,
], $token);
foreach ($page["records"] as $rec) {
    echo $rec["record_id"], " ", $rec["doc"]["title"], "\n";
}

call("/collections/plans/records", [
    "title" => "Q1 FY26 capacity plan - Payments Platform",
    "period" => "Q1 FY26 - Payments West",
    "team" => "Rita Voss, Sam Iyer",
    "summary" => "6 people at 42%, peak week 50%; 512 h of upcoming work, 115 h unstaffed",
    "outcome" => "at-risk", "likely" => 802800,
    "head_count" => 34, "check_fails" => 0,
    "ran_at" => gmdate("c"), "doc_md" => $planMarkdown,
], $token);

$hits = call("/collections/plans/similar",
    ["text" => "the quarter that hung on one SRE", "limit" => 8], $token);
foreach ($hits["records"] ?? $hits as $rec) {
    printf("%.3f %s\n", $rec["score"], $rec["doc"]["summary"]);
}
var page = await Call("/collections/plans/query", new
{
    where = new { outcome = new { eq = "unreachable" } },
    sort = new { field = "ran_at", dir = "desc" },
    limit = 20
}, token);
foreach (var rec in page.GetProperty("records").EnumerateArray())
    Console.WriteLine(rec.GetProperty("doc").GetProperty("title").GetString());

await Call("/collections/plans/records", new
{
    title = "Q1 FY26 capacity plan - Payments Platform",
    period = "Q1 FY26 - Payments Platform",
    team = "Rita Voss, Sam Iyer, Nina Park",
    summary = "6 people at 42%, peak week 50%; 115 h of upcoming work unstaffed",
    outcome = "closable", utilisation = 0.42,
    head_count = 6, unmet_hours = 115, check_fails = 0,
    ran_at = DateTime.UtcNow.ToString("o"), doc_md = planMarkdown
}, token);

var hits = await Call("/collections/plans/similar",
    new { text = "the quarter that hung on one SRE", limit = 8 }, token);
Console.WriteLine(hits.ToString());

Three traps, all verified live. Every where entry must be an operator object - {"outcome":"unreachable"} is rejected, {"outcome":{"eq":"unreachable"}} is right. Ordering is the sort object; order_by is accepted and then silently ignored, leaving you with created_at desc. And record creation posts to /collections/plans/records, not to the collection root.

Data endpoints share 120 requests/min; /collections/{name}/similar is 30/min per IP and costs roughly an order of magnitude more than a where filter - use the filter whenever an exact match would do, and never fire a similarity query per keystroke. Vector indexing is asynchronous, so a similar call immediately after a write can lag by seconds. There is no backfill: records written before an embed field existed are never vectorized. Storage quotas that matter here: 64 KB per document, 10 000 records per collection, 1 000 records per owner.