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.
Error codes
| code | status | what it means |
|---|---|---|
unauthorized | 401 | Missing or stale token. Mint a guest token or sign in again. |
forbidden | 403 | The token belongs to a different app. |
payment_required | 402 | Balance below min_credits. Call /estimate first and compare against /me. |
validation_error | 400 | Malformed body. error.details names the field. A where value that is not an operator object lands here. |
rate_limited | 429 | Back off. /similar is 30 req/min per IP, tighter than the other data endpoints. |
not_found | 404 | Unknown job or record id. |
internal | 5xx | Retry with the SAME Idempotency-Key - it returns the original job instead of billing again. |
/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"
}
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
| check | how it fails |
|---|---|
person_actions partition | A 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 partition | A 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. |
| vocabulary | A move outside reduce / backfill / absorb / hold, or a verdict outside staffed / partially-staffed / unstaffed / defer. |
| absorb sanity | Telling 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 arithmetic | The headroom of everyone asked to absorb is summed and printed against the measured need, so "spread it around" is answered with a number. |
| figure grounding | Every 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 time | If 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 gap | An unreachable shortfall where every initiative is called staffed or partially staffed, with nothing deferred or declared unstaffed, fails. |
| scenarios | No scenarios at all fails; scenarios without a do-nothing row warn, because there is then nothing to compare the proposed changes against. |
| document checks | Run 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 guard | Before 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. |
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.