Plan a new hire's first ninety days from your own pipeline
Send the measured facts — the business-day calendar, every access item with its
order-by date and overdue status, the roster, the Day 1 timetable and the week-1 1:1
slots — and get back one JSON object: role_tasks for that specific
role, one_on_ones (exactly one topic per scheduled teammate),
access_decisions (exactly one grant or
not-needed per tool), goals at 30, 60 and 90 days each with a
measure, a reading list, a sendable welcome_email,
risks and unverified. The interesting part is that all of it is
mechanically checkable, and the checker ships with the app:
/rampkit.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 returned date tested against the calendar, every named person
looked up in the roster — before a plan ever reaches a manager. 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
day-one. 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. 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. |
.ics calendar and every check — is in
/rampkit.js. Load it in Node with a global.window = {} stub and
RampKit.analyze({hire, roster, access, policy, depth, as_of}) gives you the
same facts this API expects, from
RampKit.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 plan 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 plan 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":"day-one"}'
# => {"data":{"token":"aut_...","subject_type":"guest","credits":0}}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "day-one"
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 = "day-one";
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 = "day-one"
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 = "day-one";
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 = "day-one"
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 = "day-one";
$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 = "day-one";
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":"day-one"}}
# 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 date: Day 1 through Day 5, the review dates, and every order-by date
are already here, and anything it returns is checked against them. Compute this object
with RampKit.factsForModel(RampKit.analyze(...)) from
/rampkit.js, or assemble it yourself in the shape below.
{
"depth": "light | standard | regulated",
"note": "free-text steer (may be empty)",
"facts": {
"hire": { "name": "Priya Raman", "role": "Senior Backend Engineer", "level": "L5",
"team": "Payments Platform", "manager": "Dana Whitfield",
"location": "Remote (Berlin)", "tz": "", "employment": "Full-time",
"role_family": "engineering", "workday": "09:00-17:30", "extra": [] },
"as_of": "2026-08-17",
"start_date": "2026-08-24",
"start_weekday": "Monday",
"start_assumed": false,
"business_days": [{ "n": 1, "iso": "2026-08-24", "weekday": "Monday" }],
"week1_end": "2026-08-31",
"milestones": [{ "label": "30-day", "review_on": "2026-09-23",
"weekday": "Wednesday", "rolled": false }],
"holidays": [{ "date": "2026-08-28", "label": "Summer company holiday" }],
"trainings": ["Security awareness", "Code of conduct"],
"policy_notes": [],
"roster": [{ "id": "p2", "name": "Marco Silva", "role": "Staff Engineer",
"team": "Payments Platform", "functions": [], "is_manager": false }],
"one_on_ones_scheduled": [{ "person_id": "p2", "name": "Marco Silva",
"role": "Staff Engineer", "day": 2,
"date": "2026-08-25", "time": "10:00-10:30" }],
"one_on_ones_unplaced": [{ "person_id": "p9", "name": "Rui Alves",
"reason": "week 1 holds 4 days x 3 slots = 12" }],
"buddy": { "person_id": "p3", "name": "Aiko Tanaka", "why": ["same team"],
"named_by_user": false },
"buddy_candidates": [{ "person_id": "p2", "name": "Marco Silva", "score": 5, "why": [] }],
"access_source": "pasted | catalogue",
"access": [{ "id": "t1", "tool": "Laptop and peripherals", "level": "hardware",
"owner": "IT", "lead_days": 10, "lead_assumed": false,
"order_by": "2026-08-10", "status": "overdue",
"slack_business_days": -5, "requires": [] }],
"prestart_tasks": [{ "id": "s1", "task": "Send the welcome email",
"owner": "Dana Whitfield", "due": "2026-08-17",
"status": "due-soon", "slack_business_days": 0 }],
"day1": { "date": "2026-08-24", "meeting_minutes": 255, "cap": 300,
"slots": [{ "time": "09:00-09:45", "activity": "Welcome and orientation",
"with": "Dana Whitfield" }],
"moved_to_day2": [] },
"readiness": { "level": "ready | at-risk | blocked", "why": ["..."] },
"input_gaps": [{ "id": "no_hr", "gap": "no HR contact", "detail": "..." }],
"counts": { "roster": 6, "access": 12, "overdue": 1, "due_soon": 0, "ones": 3,
"ones_unplaced": 0, "day1_slots": 7, "day1_overflow": 0,
"trainings": 3, "holidays": 1, "gaps": 0 }
},
"hire_block": "the hire details as pasted",
"roster_excerpt": "the roster, cut on whole rows with the header kept",
"access_excerpt": "the access list, cut the same way",
"policy_excerpt": "the policy box",
"current_datetime": "2026-08-17T09:00:00+02:00 (Monday)"
}
as_of is what overdue and
slack_business_days are measured from, so it is what makes "already late" a
fact rather than an opinion - pin it in tests and the output stops drifting.
input_gaps is the honest record of what the operator did not supply: when it
contains no_hr, a task owned by the generic HR is correct and is
not counted as an invented person.
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 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 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: day-one: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 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 = "day-one:" + 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["role_tasks"]), "role tasks,",
len(plan["access_decisions"]), "access decisions")
print("charged", job.get("charged_credits"), "credits")
const key = "day-one:" + 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 plan = JSON.parse(job.output.output);
if (job.truncated) console.warn("reply cut short by the balance - partial");
console.log(plan.title, plan.role_tasks.length, "tasks,", plan.access_decisions.length, "decisions");
data, err = call("/run", input, token, "day-one: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 plan map[string]any
json.Unmarshal([]byte(job.Output.Output), &plan)
fmt.Println(plan["title"], "charged", job.ChargedCredits, "truncated", job.Truncated)
String started = call("/run", INPUT_JSON, token, "day-one: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 plan JSON as a string
started = call("/run", INPUT, token: token, idem: "day-one: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
plan = JSON.parse(job["output"]["output"])
warn "reply cut short by the balance - partial" if job["truncated"]
puts "#{plan["title"]}: #{plan["role_tasks"].length} tasks, charged #{job["charged_credits"]}"
$started = call("/run", $input, $token, "day-one:9f2ac41b:a1");
do {
$job = call("/jobs/" . $started["job_id"], null, $token);
if (in_array($job["status"], ["succeeded", "failed"], true)) break;
sleep(2);
} while (true);
$plan = json_decode($job["output"]["output"], true);
if (!empty($job["truncated"])) fwrite(STDERR, "reply cut short - partial\n");
printf("%s: %d tasks, charged %d\n", $plan["title"], count($plan["role_tasks"]), $job["charged_credits"]);
var started = await Call("/run", input, token, "day-one: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 plan = JsonDocument.Parse(job.GetProperty("output").GetProperty("output").GetString()!);
Console.WriteLine(plan.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:
one_on_ones carries exactly one entry per scheduled
person_id, and access_decisions exactly one per access
id. Count them - an id appearing twice is as much a failure as one missing,
and asserting presence alone will not catch it.
{
"title": "Onboarding plan: Priya Raman, Senior Backend Engineer, Payments Platform",
"readiness_note": "One or two sentences agreeing with facts.readiness and saying what to do about anything overdue.",
"role_tasks": [
{ "phase": "pre-start | day1 | week1 | 30-day | 60-day | 90-day",
"title": "Request a loaner laptop from IT",
"owner": "IT",
"due": "2026-08-19",
"day": 3,
"why": "the ordered machine cannot arrive by the start date" }
],
"one_on_ones": [
{ "person_id": "p2", "name": "Marco Silva",
"topic": "how the settlement retry path actually behaves" }
],
"access_decisions": [
{ "tool_id": "t1", "decision": "grant | not-needed", "level": "hardware",
"why": "cannot start without it" }
],
"goals": {
"d30": [{ "goal": "Ship one small change to the ledger service",
"measure": "a merged pull request running in production" }],
"d60": [{ "goal": "...", "measure": "..." }],
"d90": [{ "goal": "...", "measure": "..." }]
},
"reading": [{ "title": "Payments architecture overview", "why": "the system she joins" }],
"welcome_email": "markdown string, addressed by name, with the first working day",
"risks": ["The laptop order is five business days past its order-by date."],
"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 |
|---|---|
one_on_ones partition | A scheduled teammate with no topic, a teammate named twice, a topic for someone in one_on_ones_unplaced, or a person_id not in facts.roster. |
access_decisions partition | A tool with no decision, a tool decided twice, a tool_id that is not in facts.access, or a decision outside grant / not-needed. |
| date legality | Any due on a weekend or a listed holiday, a pre-start due after start_date, or a week-1 day outside 1–5. The claim is reported as sent, with the next working day beside it - nothing is silently corrected before the check runs. |
| people grounding | A role_tasks[].owner who is neither in facts.roster nor the manager nor the hire nor one of the generic service roles. |
| goal measurability | An empty horizon fails; a goal with no measure warns. |
| overdue honesty | When facts.counts.overdue is above zero and no entry in risks names one of those items. |
| document checks | Run on the rendered plan: a missing section, a heading with nothing under it, a surviving [Name] placeholder, an ISO date in the document that is not a working day, a measured tool or scheduled teammate the document never names, or overdue work the document does not mention. |
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: day-one:9f2ac41b:a1' \
-d @input.json
# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"{\"title\":\"Onboarding 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", "day-one: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": "day-one: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 plan = 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", "day-one: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 plan JSON")
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", "day-one: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"] = "day-one: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
plan = 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: day-one: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);
$plan = 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", "day-one: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 plan = 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, hire_role,
team, summary, readiness,
start_date (all strings), task_count 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, hire_role,
team and summary - the summary is the one that earns its place,
because "the one where the laptop was late" lives in the readiness reason, not the title.
# Exact filter: every plan that came back blocked, 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":{"readiness":{"eq":"blocked"}},
"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":"Priya Raman - Senior Backend Engineer","hire_role":"Senior Backend Engineer",
"team":"Payments Platform","summary":"joining Payments on 2026-08-24; readiness ready",
"readiness":"ready","start_date":"2026-08-24","task_count":34,"check_fails":0,
"ran_at":"2026-08-17T09:00:00Z","plan_md":"# Onboarding plan..."}'
# Semantic search over title, hire_role, 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 one where the laptop was late","limit":8}'
# Exact filter - cheap, and the right tool whenever the question has an exact answer.
page = call("/collections/plans/query", {
"where": {"readiness": {"eq": "blocked"}},
"sort": {"field": "ran_at", "dir": "desc"},
"limit": 20
}, token=TOKEN)
for rec in page["records"]:
print(rec["record_id"], rec["doc"]["title"], rec["doc"]["start_date"])
# Write one. The path ends in /records.
call("/collections/plans/records", {
"title": "Priya Raman - Senior Backend Engineer",
"hire_role": "Senior Backend Engineer",
"team": "Payments Platform",
"summary": "joining Payments Platform on 2026-08-24; readiness ready",
"readiness": "ready", "start_date": "2026-08-24",
"task_count": 34, "check_fails": 0,
"ran_at": "2026-08-17T09:00:00Z", "plan_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 one where the laptop was late", "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: { readiness: { eq: "blocked" } },
sort: { field: "ran_at", dir: "desc" },
limit: 20
}, { token: TOKEN });
await call("/collections/plans/records", {
title: "Priya Raman - Senior Backend Engineer",
hire_role: "Senior Backend Engineer",
team: "Payments Platform",
summary: "joining Payments Platform on 2026-08-24; readiness ready",
readiness: "ready", start_date: "2026-08-24",
task_count: 34, check_fails: 0,
ran_at: new Date().toISOString(), plan_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 one where the laptop was late", 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{"readiness": map[string]any{"eq": "blocked"}},
"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 one where the laptop was late", "limit": 8}, token, "")
fmt.Println(string(data))
String body = "{\"where\":{\"readiness\":{\"eq\":\"blocked\"}},"
+ "\"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\":\"Priya Raman - Senior Backend Engineer\","
+ "\"hire_role\":\"Senior Backend Engineer\",\"team\":\"Payments Platform\","
+ "\"summary\":\"joining Payments Platform on 2026-08-24; readiness ready\","
+ "\"readiness\":\"ready\",\"start_date\":\"2026-08-24\",\"task_count\":34,"
+ "\"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 one where the laptop was late\",\"limit\":8}", token, null));
page = call("/collections/plans/query", {
"where" => { "readiness" => { "eq" => "blocked" } },
"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" => "Priya Raman - Senior Backend Engineer",
"hire_role" => "Senior Backend Engineer",
"team" => "Payments Platform",
"summary" => "joining Payments Platform on 2026-08-24; readiness ready",
"readiness" => "ready", "start_date" => "2026-08-24",
"task_count" => 34, "check_fails" => 0,
"ran_at" => Time.now.utc.iso8601, "plan_md" => plan_markdown
}, token: token)
hits = call("/collections/plans/similar",
{ "text" => "the one where the laptop was late", "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" => ["readiness" => ["eq" => "blocked"]],
"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" => "Priya Raman - Senior Backend Engineer",
"hire_role" => "Senior Backend Engineer",
"team" => "Payments Platform",
"summary" => "joining Payments Platform on 2026-08-24; readiness ready",
"readiness" => "ready", "start_date" => "2026-08-24",
"task_count" => 34, "check_fails" => 0,
"ran_at" => gmdate("c"), "plan_md" => $planMarkdown,
], $token);
$hits = call("/collections/plans/similar",
["text" => "the one where the laptop was late", "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 { readiness = new { eq = "blocked" } },
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 = "Priya Raman - Senior Backend Engineer",
hire_role = "Senior Backend Engineer",
team = "Payments Platform",
summary = "joining Payments Platform on 2026-08-24; readiness ready",
readiness = "ready", start_date = "2026-08-24",
task_count = 34, check_fails = 0,
ran_at = DateTime.UtcNow.ToString("o"), plan_md = planMarkdown
}, token);
var hits = await Call("/collections/plans/similar",
new { text = "the one where the laptop was late", limit = 8 }, token);
Console.WriteLine(hits.ToString());
Three traps, all verified live. Every where entry must be an
operator object - {"readiness":"blocked"} is rejected,
{"readiness":{"eq":"blocked"}} 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.