Drive Org Pulse from your own code
Everything the web app does, it does through this API and nothing else. Paste an employee roster, get back a verdict, the key metrics with their arithmetic, the risks the data supports, sequenced recommendations, and a stated methodology. Every example below is shown in eight languages — pick one and it stays picked.
https://api.skillsafe.ai/v1/app-api. There is
no /apps/{slug}/ segment — the slug is bound to your
token when you mint it at /guest, and adding a slug path returns
404 not_found. Every response is the envelope
{"ok":true,"data":{…}} or {"ok":false,"error":{…}}.
What the model is and is not asked to do
Org Pulse is deliberately narrow, and knowing the boundary saves you from expecting answers it will refuse to give. It is an analyst of the data you send — never a benchmark source.
It will:
- compute headcount by team, level and location as of your
as_ofdate; - compute trailing-twelve-month attrition with the denominator stated, and say where it concentrates;
- report spans of control, management layers and tenure mix, flagging wide spans and layers of one;
- name the risks the numbers support, and sequence the recommendations that address them;
- state what it excluded and why, and what your columns simply cannot answer.
It will not:
- invent an industry benchmark, a "typical" attrition rate or a survey norm — if you
want a comparison, put your own benchmark numbers in
contextand it will use them and say they are yours; - infer demographics that are not columns in your data. No gender, ethnicity or age is ever guessed from a name, a role or a location;
- guess a missing cell — a row without a start date is excluded from tenure maths and named as a gap;
- speculate about why anyone left, or name an individual as a flight risk;
- give employment-law advice — legal questions are routed to counsel under Recommendations.
roster field is transmitted to
the platform on /estimate, /run and /run-stream.
If you would rather not send real names, substitute stable identifiers first — the
analysis needs the manager-to-name join to resolve spans of control, not the names
themselves. (The browser app's own org-stats scan runs locally and uploads nothing, but
that is the free lane, not this API.)
Errors
Failures come back as {"ok":false,"error":{"code":…,"message":…}} with a matching HTTP status.
| Code | Status | What it means |
|---|---|---|
unauthorized | 401 | Missing, malformed or expired token. Mint a new one at POST /guest or sign in on the tokens page. |
payment_required | 402 | Balance below min_credits. Check /estimate against /me before running — that is the whole point of the free estimate. |
not_found | 404 | Usually a path that includes /apps/{slug}/. It does not exist. Use the bare routes above. |
validation_error | 400 | The body was not the input object, or a field had the wrong type. Note the body is the input itself, not {"input":{…}}. |
rate_limited | 429 | Back off and retry. Reuse the same Idempotency-Key so the retry cannot double-bill. |
job_failed | 200 | Returned inside a terminal job rather than as an HTTP error. Read status on every job before using output. |
1. A tiny client
Everything below builds on this. Get your token from the tokens page — no DevTools console required — and keep it in your environment or secret store, never in source control.
# Every call is one request to the same host. Keep your token in a shell
# variable; never commit it.
export API="https://api.skillsafe.ai/v1/app-api"
export SKILLSAFE_TOKEN="paste-your-token-here" # see /tokens.html
# A helper: call <path> <json-body> (omit the body for a GET)
call() {
if [ -z "$2" ]; then
curl -sS "$API$1" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
else
curl -sS -X POST "$API$1" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
fi
}import json, os, urllib.request
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def call(path, body=None, method=None, token=TOKEN):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(API + path, data=data,
method=method or ("POST" if data else "GET"))
if token:
req.add_header("Authorization", "Bearer " + token)
if data:
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
payload = json.load(r)
# Every response is the same envelope: {"ok":true,"data":{...}}
# or {"ok":false,"error":{"code":"...","message":"..."}}
if not payload.get("ok"):
raise RuntimeError(payload.get("error"))
return payload["data"]const API = "https://api.skillsafe.ai/v1/app-api";
// Read this from your own secret store. Never hard-code a real token.
const TOKEN = "YOUR_TOKEN";
async function call(path, body, method) {
const res = await fetch(API + path, {
method: method || (body ? "POST" : "GET"),
headers: {
...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}),
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json();
if (!json.ok) throw new Error(json.error?.code + ": " + json.error?.message);
return json.data;
}package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const API = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN")
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
var rdr *bytes.Reader
method := "GET"
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
method = "POST"
} else {
rdr = bytes.NewReader(nil)
}
req, _ := http.NewRequest(method, API+path, rdr)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}import java.net.URI;
import java.net.http.*;
import java.util.Optional;
public class OrgPulse {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN =
Optional.ofNullable(System.getenv("SKILLSAFE_TOKEN")).orElse("YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
// Returns the raw JSON body; use your preferred JSON library to read it.
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(API + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody == null) {
b.GET();
} else {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
return res.body(); // {"ok":true,"data":{...}} or {"ok":false,"error":{...}}
}
}require "json"
require "net/http"
require "uri"
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def call(path, body = nil, method: nil)
uri = URI(API + path)
klass = (method || (body ? "POST" : "GET")) == "POST" ? Net::HTTP::Post : Net::HTTP::Get
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.dump(body)
end
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload["error"]["code"]}: #{payload["error"]["message"]}" unless payload["ok"]
payload["data"]
end<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";
function call(string $path, ?array $body = null, ?string $method = null) {
global $TOKEN;
$headers = ["Authorization: Bearer $TOKEN"];
$opts = ["http" => ["method" => $method ?? ($body ? "POST" : "GET"),
"ignore_errors" => true]];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
$opts["http"]["content"] = json_encode($body);
}
$opts["http"]["header"] = implode("\r\n", $headers);
$raw = file_get_contents(API . $path, false, stream_context_create($opts));
$payload = json_decode($raw, true);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
public static class OrgPulse {
const string API = "https://api.skillsafe.ai/v1/app-api";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new HttpClient();
public static async Task<JsonElement> Call(string path, object body = null, string method = null) {
var req = new HttpRequestMessage(
new HttpMethod(method ?? (body is null ? "GET" : "POST")), API + path);
req.Headers.Add("Authorization", "Bearer " + Token);
if (body is not null)
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
var root = doc.RootElement;
if (!root.GetProperty("ok").GetBoolean())
throw new Exception(root.GetProperty("error").GetProperty("code").GetString());
return root.GetProperty("data").Clone();
}
}2. Get a token
A guest token is enough for /me and the free /estimate. Metered runs need a personal token, which you get by signing in on the tokens page and copying the shell export.
# A guest token. The slug goes in the BODY - there is no /apps/{slug}/
# path segment anywhere in this API, and no X-App-Slug header.
curl -sS -X POST "$API/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"org-pulse"}'
# -> {"ok":true,"data":{"token":"...","subject_type":"guest","credits":0}}
#
# Guest tokens can call /me and /estimate. Metered runs need a personal
# token: open https://org-pulse.skillsafe.ai/tokens.html and sign in.guest = call("/guest", {"slug": "org-pulse"}, token=None)
TOKEN = guest["token"]
print(guest["subject_type"], guest["credits"])const guest = await (await fetch(API + "/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "org-pulse" }),
})).json();
const token = guest.data.token;raw, err := call("/guest", map[string]string{"slug": "org-pulse"})
if err != nil {
panic(err)
}
var guest struct {
Token string `json:"token"`
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
json.Unmarshal(raw, &guest)
token = guest.TokenString guest = call("/guest", "{\"slug\":\"org-pulse\"}");
// Pull data.token out of the envelope with your JSON library and reuse it.
System.out.println(guest);guest = call("/guest", { "slug" => "org-pulse" })
token = guest["token"]
puts "#{guest["subject_type"]} with #{guest["credits"]} credits"$guest = call("/guest", ["slug" => "org-pulse"]);
$TOKEN = $guest["token"];
echo $guest["subject_type"], " ", $guest["credits"], PHP_EOL;var guest = await OrgPulse.Call("/guest", new { slug = "org-pulse" });
var token = guest.GetProperty("token").GetString();3. Check the session
Confirms the token works and tells you the balance you will be spending. Free.
call /me
# -> {"ok":true,"data":{"subject_type":"user","subject_id":"...",
# "credits":48210,"slug":"org-pulse"}}me = call("/me")
print(me["subject_type"], me["credits"])const me = await call("/me");
console.log(me.subject_type, me.credits);raw, _ := call("/me", nil)
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)System.out.println(call("/me", null));me = call("/me")
puts "#{me["subject_type"]} #{me["credits"]}"$me = call("/me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;var me = await OrgPulse.Call("/me");
Console.WriteLine(me.GetProperty("credits").GetInt32());4. Build the input
Five fields, and this is exactly what app.js sends — no more, no less.
cat > input.json <<'JSON'
{
"roster": "employee_id,name,team,level,location,manager,start_date,end_date\nE001,Ada Bell,Engineering,L5,Berlin,Bram Ost,2021-03-01,\nE002,Cy Nolan,Go-to-Market,L4,Remote,Dana Pike,2022-07-15,2026-02-28\n",
"focus": "full report",
"as_of": "2026-08-01",
"context": "Series A, 30-ish people. Preparing a board update.",
"orgscan": "parsed 2 rows: 1 active, 1 departed\nactive by team: Engineering 1\ntrailing-12-month departures: 1 (Go-to-Market 1)\nmechanical flags: none"
}
JSONdef build_input(roster, focus="full report", as_of=None, context="", orgscan=""):
return {
"roster": roster, # verbatim CSV/TSV/markdown table
"focus": focus, # full report | headcount | attrition | org health
"as_of": as_of or date.today().isoformat(),
"context": context, # optional
"orgscan": orgscan, # optional; see the note below
}function buildInput(roster, focus = "full report", asOf, context = "", orgscan = "") {
return {
roster, // verbatim table text
focus, // one of the four lenses
as_of: asOf ?? new Date().toISOString().slice(0, 10),
context,
orgscan,
};
}type Input struct {
Roster string `json:"roster"`
Focus string `json:"focus"`
AsOf string `json:"as_of"`
Context string `json:"context,omitempty"`
OrgScan string `json:"orgscan,omitempty"`
}
input := Input{
Roster: rosterCSV,
Focus: "full report",
AsOf: time.Now().Format("2006-01-02"),
}String inputJson = """
{
"roster": %s,
"focus": "full report",
"as_of": "2026-08-01",
"context": "",
"orgscan": ""
}
""".formatted(jsonQuote(rosterCsv));def build_input(roster, focus: "full report", as_of: nil, context: "", orgscan: "")
{
"roster" => roster,
"focus" => focus,
"as_of" => as_of || Date.today.iso8601,
"context" => context,
"orgscan" => orgscan,
}
endfunction build_input(string $roster, string $focus = "full report",
?string $asOf = null, string $context = "",
string $orgscan = ""): array {
return [
"roster" => $roster,
"focus" => $focus,
"as_of" => $asOf ?? date("Y-m-d"),
"context" => $context,
"orgscan" => $orgscan,
];
}var input = new {
roster = rosterCsv,
focus = "full report",
as_of = DateTime.UtcNow.ToString("yyyy-MM-dd"),
context = "",
orgscan = "",
};The input object in full
{
"roster": "employee_id,name,team,level,location,manager,start_date,end_date\n…",
"focus": "full report" | "headcount" | "attrition" | "org health",
"as_of": "2026-08-01",
"context": "Series A, 30-ish people. Preparing a board update.",
"orgscan": "parsed 36 rows: 27 active, 9 departed\n…"
}
| Field | Required | What it is |
|---|---|---|
roster | yes | The employee table verbatim: CSV, TSV or a markdown table. Column names are matched loosely (dept, reports_to, hire_date, last_day and friends all resolve). An empty end date means the person is active. |
focus | yes | One of full report, headcount, attrition, org health. Weights the report body; the verdict and risks always consider everything. |
as_of | yes | YYYY-MM-DD. Every "current", tenure and trailing-twelve-month figure is measured from this date. Send your month-end to match a snapshot, or a past date to reproduce an earlier figure exactly. |
context | no | Free text: company stage, what worries you, who the report is for. Benchmark numbers you supply here will be used and attributed to you — it will never invent its own. |
orgscan | no | A plain-text summary of a mechanical pre-scan (see below). Treated as untrusted hints that must be recomputed, never as truth. |
Clipping a roster that is too large
The web app caps roster at 24,000 characters. It does not cut
the tail off: it keeps the header row and both ends and drops the middle, announcing the cut
in-band with a bracketed marker line that is not a data row:
employee_id,name,team,level,location,manager,start_date,end_date E001,Ada Bell,Engineering,L5,Berlin,Bram Ost,2021-03-01, …the first N rows… [... 143 of 400 roster rows omitted from the middle to fit the size limit; the first 130 and the last 127 rows are shown ...] …the last M rows…
This matters because a roster sorted by hire date carries its newest cohort — the tenure cliff the report exists to find — in its last rows, and a head-only slice deletes exactly that. If you clip your own input, do it the same way and keep the marker: the prompt is written to recognise it, report the omission under Methodology and gaps, and lower its confidence accordingly.
The orgscan hint
Optional, and worth sending. It is a plain-text tally the browser computes locally —
headcount by team, level and location, trailing-twelve-month departures with their per-team
split, spans of control, tenure mix, and mechanical data flags. Two rules govern it: the
model must recompute rather than trust it, and it is computed over the complete
roster even when roster itself was clipped, so where the two disagree on a total
the scan holds the larger population.
parsed 36 rows: 27 active, 9 departed active by team: Engineering 12, Go-to-Market 5, Product 4, Ops 3, Design 3 active by level: L4 9, L5 7, L3 6, L6 3, L2 2 trailing-12-month departures: 6 (Go-to-Market 4, Engineering 1, Ops 1) median active tenure: 1.8 years; joined in last 6 months: 6 spans of control: Bram Ost 11, Dana Pike 5, Eve Marsh 4, Finn Roe 1 mechanical flags: - [wide_span] Bram Ost has 11 active direct reports - a span above 8 usually means the team cannot be managed closely. - [attrition_cluster] 4 of the 6 trailing-12-month departures are from Go-to-Market - attrition is clustering there. - [missing_start] Kit Vale (Design) has no start date - tenure and attrition math excludes this row.
5. Estimate first — it is free
/estimate creates no job and charges nothing. It is also the only honest way to
avoid a 402: compare min_credits against your /me balance before you
commit. hold_credits is what gets reserved, priced at the full output
cap — the actual charge is usually far lower.
call /estimate "$(cat input.json)"
# -> {"ok":true,"data":{
# "model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
# "hold_credits":1608,"min_credits":179,"sponsor_enabled":false}}
#
# No job is created and nothing is charged. hold_credits is what will be
# RESERVED, not what you will pay - the hold prices the full output cap and
# the actual charge is usually far lower.est = call("/estimate", build_input(roster_text, "full report"))
print(est["model"], est["model_alias"], est["markup_bps"])
assert est["model_alias"] == "gpt-terra"
me = call("/me")
if me["credits"] < est["min_credits"]:
raise SystemExit(f"short by {est['min_credits'] - me['credits']} credits")const est = await call("/estimate", buildInput(rosterText, "full report"));
console.log(est.model, est.model_alias, est.markup_bps);
const me = await call("/me");
if (me.credits < est.min_credits) {
throw new Error(`short by ${est.min_credits - me.credits} credits`);
}raw, _ := call("/estimate", input)
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(raw, &est)
fmt.Println(est.Model, est.ModelAlias, est.MarkupBps, est.HoldCredits)String est = call("/estimate", inputJson);
System.out.println(est);est = call("/estimate", build_input(roster_text, "full report"))
puts "#{est["model"]} hold=#{est["hold_credits"]} min=#{est["min_credits"]}"
me = call("/me")
abort("not enough credits") if me["credits"] < est["min_credits"]$est = call("/estimate", build_input($rosterText, "full report"));
printf("%s hold=%d min=%d\n", $est["model"], $est["hold_credits"], $est["min_credits"]);var est = await OrgPulse.Call("/estimate", input);
Console.WriteLine(est.GetProperty("model").GetString());
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());min_credits and hold_credits still
executes, with a reduced output cap, and comes back with "truncated": true.
Treat that as an incomplete report and say so to your user — do not present a clipped
report as a finished one.
6. Run it
Two things trip people up here. The body is the input object directly, not
{"input": {…}}. And Idempotency-Key is not optional in practice:
derive it from a hash of the input plus an attempt counter, so a network retry replays the
first run instead of billing a second one.
# The body is the input object DIRECTLY - not {"input": {...}}.
# Idempotency-Key is a content hash of the input plus one counter per
# attempt: a retried network call replays instead of billing twice.
curl -sS -X POST "$API/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: org-pulse-$(shasum -a 256 input.json | cut -c1-16)-1" \
-d @input.json
# -> {"ok":true,"data":{"job_id":"job_...","status":"queued"}}
# Then poll until it is terminal:
call /jobs/job_xxx
# -> {"ok":true,"data":{"status":"succeeded","charged_credits":412,
# "truncated":false,"output":{"output":"VERDICT: Watch\nSCOPE: ..."}}}import hashlib, json, time
def idempotency_key(payload, attempt=1):
digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
return f"org-pulse-{digest[:16]}-{attempt}"
def run(payload, attempt=1):
body = json.dumps(payload).encode()
req = urllib.request.Request(API + "/run", data=body, method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", idempotency_key(payload, attempt))
with urllib.request.urlopen(req) as r:
job = json.load(r)["data"]
while job["status"] not in ("succeeded", "failed"):
time.sleep(1)
job = call("/jobs/" + job["job_id"])
return job
job = run(build_input(roster_text, "full report"))
print(job["charged_credits"], job["truncated"])
text = job["output"]["output"]import { createHash } from "node:crypto";
const idempotencyKey = (payload, attempt = 1) =>
`org-pulse-${createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 16)}-${attempt}`;
async function run(payload, attempt = 1) {
const res = await fetch(API + "/run", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey(payload, attempt),
},
body: JSON.stringify(payload), // the input object itself
});
let job = (await res.json()).data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 1000));
job = await call(`/jobs/${job.job_id}`);
}
return job;
}
const job = await run(buildInput(rosterText, "full report"));
const text = job.output.output;func run(input any, attempt int) (map[string]any, error) {
b, _ := json.Marshal(input)
sum := sha256.Sum256(b)
req, _ := http.NewRequest("POST", API+"/run", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key",
fmt.Sprintf("org-pulse-%x-%d", sum[:8], attempt))
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
json.NewDecoder(res.Body).Decode(&env)
var job map[string]any
json.Unmarshal(env.Data, &job)
for job["status"] != "succeeded" && job["status"] != "failed" {
time.Sleep(time.Second)
raw, _ := call("/jobs/"+job["job_id"].(string), nil)
json.Unmarshal(raw, &job)
}
return job, nil
}import java.security.MessageDigest;
import java.util.HexFormat;
static String idempotencyKey(String body, int attempt) throws Exception {
byte[] d = MessageDigest.getInstance("SHA-256").digest(body.getBytes());
return "org-pulse-" + HexFormat.of().formatHex(d).substring(0, 16) + "-" + attempt;
}
static String run(String inputJson) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(API + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", idempotencyKey(inputJson, 1))
.POST(HttpRequest.BodyPublishers.ofString(inputJson))
.build();
// Poll GET /jobs/{job_id} until status is succeeded or failed.
return HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
}require "digest"
def idempotency_key(payload, attempt = 1)
"org-pulse-#{Digest::SHA256.hexdigest(JSON.dump(payload))[0, 16]}-#{attempt}"
end
def run(payload, attempt = 1)
uri = URI(API + "/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idempotency_key(payload, attempt)
req.body = JSON.dump(payload)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
job = JSON.parse(res.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 1
job = call("/jobs/#{job["job_id"]}")
end
job
endfunction idempotency_key(array $payload, int $attempt = 1): string {
return "org-pulse-" . substr(hash("sha256", json_encode($payload)), 0, 16) . "-$attempt";
}
function run(array $payload, int $attempt = 1): array {
global $TOKEN;
$opts = ["http" => ["method" => "POST", "ignore_errors" => true,
"header" => implode("\r\n", [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
"Idempotency-Key: " . idempotency_key($payload, $attempt),
]),
"content" => json_encode($payload)]];
$job = json_decode(file_get_contents(API . "/run", false,
stream_context_create($opts)), true)["data"];
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(1);
$job = call("/jobs/" . $job["job_id"]);
}
return $job;
}using System.Security.Cryptography;
static string IdempotencyKey(string body, int attempt = 1) {
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body)));
return $"org-pulse-{hash[..16].ToLowerInvariant()}-{attempt}";
}
static async Task<JsonElement> Run(object input) {
var body = JsonSerializer.Serialize(input);
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", "Bearer " + Token);
req.Headers.Add("Idempotency-Key", IdempotencyKey(body));
req.Content = new StringContent(body, Encoding.UTF8, "application/json");
// Then poll GET /jobs/{job_id} until status is succeeded or failed.
var res = await Http.SendAsync(req);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
return doc.RootElement.GetProperty("data").Clone();
}7. Or stream it
Same body, same idempotency rule, server-sent events instead of polling. The five
## headings arrive in contract order, which makes them real progress signals
— the web app advances its staged progress display on exactly these.
curl -N -sS -X POST "$API/run-stream" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: org-pulse-abc123-1" \
-d @input.json
# Server-sent events. The report arrives in order, so the "## " headings
# double as progress markers - which is exactly how the web app drives its
# staged progress display:
#
# event: job data: {"job_id":"job_..."}
# event: delta data: {"text":"VERDICT: Watch\n"}
# event: delta data: {"text":"## Report\n"}
# event: done data: {"status":"succeeded","charged_credits":412,
# "truncated":false,"output":{"output":"..."}}
#
# Always prefer the `done` payload's output over your concatenated deltas -
# the SSE tail can be dropped.import urllib.request, json
def run_stream(payload, on_delta, attempt=1):
body = json.dumps(payload).encode()
req = urllib.request.Request(API + "/run-stream", data=body, method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", idempotency_key(payload, attempt))
done, event = None, None
with urllib.request.urlopen(req) as r:
for raw in r:
line = raw.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: "):
data = json.loads(line[6:])
if event == "delta":
on_delta(data["text"])
elif event == "done":
done = data
return done
done = run_stream(build_input(roster_text, "full report"),
lambda t: print(t, end="", flush=True))
text = done["output"]["output"] # authoritative; deltas can drop the tailasync function runStream(payload, onDelta, attempt = 1) {
const res = await fetch(API + "/run-stream", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey(payload, attempt),
},
body: JSON.stringify(payload),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", event = null, done = null;
for (;;) {
const { value, done: finished } = await reader.read();
if (finished) break;
buf += decoder.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);
else if (line.startsWith("data: ")) {
const data = JSON.parse(line.slice(6));
if (event === "delta") onDelta(data.text);
else if (event === "done") done = data;
}
}
}
return done;
}req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "org-pulse-abc123-1")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
scanner := bufio.NewScanner(res.Body)
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
var event string
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: "):
var d map[string]any
json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &d)
if event == "delta" {
fmt.Print(d["text"])
}
}
}HttpRequest req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", idempotencyKey(inputJson, 1))
.POST(HttpRequest.BodyPublishers.ofString(inputJson))
.build();
HttpResponse<java.util.stream.Stream<String>> res =
HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String[] event = { null };
res.body().forEach(line -> {
if (line.startsWith("event: ")) event[0] = line.substring(7);
else if (line.startsWith("data: ") && "delta".equals(event[0]))
System.out.print(line.substring(6)); // {"text":"..."}
});uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idempotency_key(payload, 1)
req.body = JSON.dump(payload)
event = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line.chomp!
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ")
data = JSON.parse(line[6..])
print data["text"] if event == "delta"
end
end
end
end
end$opts = ["http" => ["method" => "POST", "ignore_errors" => true,
"header" => implode("\r\n", [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
"Idempotency-Key: " . idempotency_key($payload, 1),
]),
"content" => json_encode($payload)]];
$stream = fopen(API . "/run-stream", "r", false, stream_context_create($opts));
$event = null;
while (($line = fgets($stream)) !== false) {
$line = rtrim($line, "\n");
if (str_starts_with($line, "event: ")) {
$event = substr($line, 7);
} elseif (str_starts_with($line, "data: ")) {
$data = json_decode(substr($line, 6), true);
if ($event === "delta") echo $data["text"];
}
}
fclose($stream);var req = new HttpRequestMessage(HttpMethod.Post,
"https://api.skillsafe.ai/v1/app-api/run-stream");
req.Headers.Add("Authorization", "Bearer " + Token);
req.Headers.Add("Idempotency-Key", IdempotencyKey(body));
req.Content = new StringContent(body, Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string ev = null;
while (await reader.ReadLineAsync() is string line) {
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta") {
using var d = JsonDocument.Parse(line[6..]);
Console.Write(d.RootElement.GetProperty("text").GetString());
}
}done payload's
output.output over your concatenated deltas, because the SSE tail can be
dropped; and if the stream dies mid-body, keep what arrived and try to parse
it. The tokens were generated and billed either way, and a partial report is worth more to
the reader than an error message.
8. Parse the reply — and check it
The reply is plain text in a fixed shape, not JSON. Four tag lines, then five
## sections in this exact order. Anything that breaks the shape should be
discarded and re-requested; the app does that once, adding a retry_note field
that spells the shape out, and treats that retry as a second billed run.
# The reply is plain text in a fixed shape, not JSON. Split it before you
# use it. A reply that breaks any contract rule should be discarded and
# re-requested - that is what the app does, once, with a retry_note.
python3 - <<'PY'
import re, sys
text = open("reply.txt").read()
verdict = re.search(r"^VERDICT:\s*(Steady|Watch|Act now)\s*$", text, re.M)
scope = re.search(r"^SCOPE:\s*(.+)$", text, re.M)
conf = re.search(r"^CONFIDENCE:\s*(\d{1,3})\s*$", text, re.M)
if not (verdict and scope and conf):
sys.exit("tag lines missing - discard this reply")
risks = re.search(r"^## Risks\n(.*?)(?=^## |\Z)", text, re.M | re.S)
empty = risks and risks.group(1).strip() in ("- None.", "- None")
if verdict.group(1) == "Steady" and not empty:
sys.exit("Steady with risks listed - the contract forbids this")
print(verdict.group(1), conf.group(1))
PYimport re
SECTIONS = ["Report", "Key metrics", "Risks", "Recommendations", "Methodology and gaps"]
def parse_result(text):
verdict = re.search(r"^VERDICT:\s*(Steady|Watch|Act now)\s*$", text, re.M)
scope = re.search(r"^SCOPE:\s*(.+)$", text, re.M)
conf = re.search(r"^CONFIDENCE:\s*(\d{1,3})\s*$", text, re.M)
summ = re.search(r"^SUMMARY:\s*(.+?)(?=\n\s*\n)", text, re.M | re.S)
if not (verdict and scope and conf and summ):
return None
if not 0 <= int(conf.group(1)) <= 100:
return None
body = {}
for i, name in enumerate(SECTIONS):
nxt = SECTIONS[i + 1] if i + 1 < len(SECTIONS) else None
stop = rf"^## {re.escape(nxt)}\s*$" if nxt else r"\Z"
m = re.search(rf"^## {re.escape(name)}\s*$\n(.*?)(?={stop})", text, re.M | re.S)
if not m:
return None # every one of the five is required
body[name] = m.group(1).strip()
def bullets(block):
items = [re.sub(r"^[-*+]\s+", "", ln).strip()
for ln in block.splitlines() if ln.strip().startswith(("-", "*", "+"))]
return [] if items in (["None."], ["None"]) else items
rep = {
"verdict": verdict.group(1),
"scope": scope.group(1).strip(),
"confidence": int(conf.group(1)),
"summary": " ".join(summ.group(1).split()),
"report": body["Report"],
"metrics": bullets(body["Key metrics"]),
"risks": bullets(body["Risks"]),
"recs": bullets(body["Recommendations"]),
"gaps": bullets(body["Methodology and gaps"]),
}
# The two rules that bind the verdict to the sections. The app renders a
# warning rather than discarding, so a human always sees the conflict.
rep["consistent"] = not (
(rep["verdict"] == "Steady" and rep["risks"]) or
(rep["verdict"] == "Act now" and not rep["risks"])
)
return repconst SECTIONS = ["Report", "Key metrics", "Risks", "Recommendations", "Methodology and gaps"];
function parseResult(text) {
const verdict = /^VERDICT:\s*(Steady|Watch|Act now)\s*$/m.exec(text);
const scope = /^SCOPE:\s*(.+)$/m.exec(text);
const conf = /^CONFIDENCE:\s*(\d{1,3})\s*$/m.exec(text);
const summary = /^SUMMARY:\s*([\s\S]+?)(?=\n\s*\n)/m.exec(text);
if (!verdict || !scope || !conf || !summary) return null;
if (+conf[1] < 0 || +conf[1] > 100) return null;
const body = {};
for (let i = 0; i < SECTIONS.length; i++) {
const next = SECTIONS[i + 1];
const stop = next ? `^## ${next}\\s*$` : "$(?![\\s\\S])";
const m = new RegExp(`^## ${SECTIONS[i]}\\s*$\\n([\\s\\S]*?)(?=${stop})`, "m").exec(text);
if (!m) return null;
body[SECTIONS[i]] = m[1].trim();
}
const bullets = (block) => {
const items = block.split("\n")
.filter((l) => /^\s*[-*+]\s+/.test(l))
.map((l) => l.replace(/^\s*[-*+]\s+/, "").trim());
return items.length === 1 && /^none\.?$/i.test(items[0]) ? [] : items;
};
const rep = {
verdict: verdict[1], scope: scope[1].trim(), confidence: +conf[1],
summary: summary[1].replace(/\s+/g, " ").trim(),
report: body.Report,
metrics: bullets(body["Key metrics"]),
risks: bullets(body.Risks),
recs: bullets(body.Recommendations),
gaps: bullets(body["Methodology and gaps"]),
};
rep.consistent = !((rep.verdict === "Steady" && rep.risks.length) ||
(rep.verdict === "Act now" && !rep.risks.length));
return rep;
}var (
reVerdict = regexp.MustCompile(`(?m)^VERDICT:\s*(Steady|Watch|Act now)\s*$`)
reScope = regexp.MustCompile(`(?m)^SCOPE:\s*(.+)$`)
reConf = regexp.MustCompile(`(?m)^CONFIDENCE:\s*(\d{1,3})\s*$`)
)
type Report struct {
Verdict, Scope, Summary string
Confidence int
Risks []string
Consistent bool
}
func parseResult(text string) (*Report, error) {
v := reVerdict.FindStringSubmatch(text)
s := reScope.FindStringSubmatch(text)
c := reConf.FindStringSubmatch(text)
if v == nil || s == nil || c == nil {
return nil, errors.New("tag lines missing")
}
conf, _ := strconv.Atoi(c[1])
// Every one of the five ## sections must be present; extract each the
// same way, then split the last four into "- " bullets.
risks := bullets(section(text, "Risks"))
rep := &Report{Verdict: v[1], Scope: s[1], Confidence: conf, Risks: risks}
rep.Consistent = !((rep.Verdict == "Steady" && len(risks) > 0) ||
(rep.Verdict == "Act now" && len(risks) == 0))
return rep, nil
}import java.util.regex.*;
static final Pattern VERDICT =
Pattern.compile("(?m)^VERDICT:\\s*(Steady|Watch|Act now)\\s*$");
static final Pattern CONF =
Pattern.compile("(?m)^CONFIDENCE:\\s*(\\d{1,3})\\s*$");
static void check(String text) {
Matcher v = VERDICT.matcher(text);
Matcher c = CONF.matcher(text);
if (!v.find() || !c.find())
throw new IllegalStateException("tag lines missing - discard the reply");
String risks = section(text, "Risks"); // your own extractor
boolean noRisks = risks.strip().matches("- None\\.?");
if (v.group(1).equals("Steady") && !noRisks)
throw new IllegalStateException("Steady with risks - contract violation");
if (v.group(1).equals("Act now") && noRisks)
throw new IllegalStateException("Act now with no risks - contract violation");
}SECTIONS = ["Report", "Key metrics", "Risks", "Recommendations", "Methodology and gaps"]
def parse_result(text)
verdict = text[/^VERDICT:\s*(Steady|Watch|Act now)\s*$/, 1]
scope = text[/^SCOPE:\s*(.+)$/, 1]
conf = text[/^CONFIDENCE:\s*(\d{1,3})\s*$/, 1]
return nil unless verdict && scope && conf
body = {}
SECTIONS.each_with_index do |name, i|
nxt = SECTIONS[i + 1]
stop = nxt ? "^## #{Regexp.escape(nxt)}\\s*$" : '\\z'
m = text[/^\#\# #{Regexp.escape(name)}\s*$\n(.*?)(?=#{stop})/m, 1]
return nil unless m
body[name] = m.strip
end
bullets = ->(b) do
items = b.lines.grep(/^\s*[-*+]\s+/).map { |l| l.sub(/^\s*[-*+]\s+/, "").strip }
items.map(&:downcase) == ["none."] ? [] : items
end
risks = bullets.call(body["Risks"])
{ verdict: verdict, scope: scope, confidence: conf.to_i, risks: risks,
consistent: !((verdict == "Steady" && !risks.empty?) ||
(verdict == "Act now" && risks.empty?)) }
endfunction parse_result(string $text): ?array {
if (!preg_match('/^VERDICT:\s*(Steady|Watch|Act now)\s*$/m', $text, $v)) return null;
if (!preg_match('/^SCOPE:\s*(.+)$/m', $text, $s)) return null;
if (!preg_match('/^CONFIDENCE:\s*(\d{1,3})\s*$/m', $text, $c)) return null;
$sections = ["Report", "Key metrics", "Risks", "Recommendations", "Methodology and gaps"];
$body = [];
foreach ($sections as $i => $name) {
$next = $sections[$i + 1] ?? null;
$stop = $next ? '^\#\# ' . preg_quote($next, "/") . '\s*$' : '\z';
$re = '/^\#\# ' . preg_quote($name, "/") . '\s*$\n(.*?)(?=' . $stop . ')/ms';
if (!preg_match($re, $text, $m)) return null; // all five are required
$body[$name] = trim($m[1]);
}
$bullets = function (string $b): array {
$items = [];
foreach (explode("\n", $b) as $line) {
if (preg_match('/^\s*[-*+]\s+(.*)$/', $line, $mm)) $items[] = trim($mm[1]);
}
return (count($items) === 1 && preg_match('/^none\.?$/i', $items[0])) ? [] : $items;
};
$risks = $bullets($body["Risks"]);
return [
"verdict" => $v[1], "scope" => trim($s[1]), "confidence" => (int) $c[1],
"risks" => $risks,
"consistent" => !(($v[1] === "Steady" && $risks) || ($v[1] === "Act now" && !$risks)),
];
}using System.Text.RegularExpressions;
static readonly Regex Verdict =
new(@"^VERDICT:\s*(Steady|Watch|Act now)\s*$", RegexOptions.Multiline);
static readonly Regex Conf =
new(@"^CONFIDENCE:\s*(\d{1,3})\s*$", RegexOptions.Multiline);
static void Check(string text) {
var v = Verdict.Match(text);
var c = Conf.Match(text);
if (!v.Success || !c.Success)
throw new InvalidOperationException("tag lines missing - discard the reply");
var risks = Section(text, "Risks"); // your own extractor
bool noRisks = Regex.IsMatch(risks.Trim(), @"^- None\.?$");
if (v.Groups[1].Value == "Steady" && !noRisks)
throw new InvalidOperationException("Steady with risks - contract violation");
if (v.Groups[1].Value == "Act now" && noRisks)
throw new InvalidOperationException("Act now with no risks - contract violation");
}The output contract
VERDICT: Act now SCOPE: 27 active people, 5 teams, as of 2026-08-01 CONFIDENCE: 72 SUMMARY: Go-to-Market lost 4 of its 9 people in the trailing twelve months while the rest of the company lost 2, and a single manager now holds 11 of the 27 active reports. ## Report ### Headcount …markdown body, ### and #### headings only, no tables… ## Key metrics - Active headcount `27` (36 rows less 9 with an end date on or before 2026-08-01) - Trailing-12-month departures `6 of 33 (18.2%)` (departures over active plus departures) ## Risks - Go-to-Market lost `4 of 9` people in twelve months, against `2` across every other team. ## Recommendations - First, fix the four rows with unusable dates so the next report can be trusted. ## Methodology and gaps - Attrition is departures in the twelve months to 2026-08-01 over headcount at the window start, approximated as active plus departures; the denominator is stated with every rate.
| Rule | Detail |
|---|---|
VERDICT | First line. Exactly one of Steady, Watch, Act now — spelled and capitalised that way. There is no fourth value. |
SCOPE | One short plain-text line, e.g. 31 people, 5 teams, as of 2026-08-01. |
CONFIDENCE | A bare integer 0–100. No percent sign, no range, no word. |
SUMMARY | One to three sentences, may wrap over several lines, ends at the first blank line. |
| Sections | All five ## headings, spelled exactly, in exactly that order, and they are the only level-2 headings in the response. |
| Report body | ### and #### headings only. No tables — bullet lists instead. |
| Bullet sections | The last four contain only - bullets. An empty one is the single bullet - None. |
The two rules that bind the verdict to the sections
These are the checks worth writing, because they catch a reply that is well-formed and still
wrong. A non-empty Risks section forbids Steady,
and Act now requires at least one risk bullet. The web app does
not silently discard a reply that breaks them — it renders the report with a visible
warning, because a human should see the contradiction rather than have it hidden. Decide
which behaviour your integration wants, but do not ignore the check.
Keeping a history yourself
The most useful number in a quarterly people report is the one that moved — headcount
27 to 31, attrition 24.0% to 18.0%. That is arithmetic over two runs, not a third run, so
store the figures from each report and diff them locally. The web app keeps its history in
per-user storage (PUT /v1/app-api/data/{key}) with a browser mirror, and computes
the comparison client-side for free. If you are building your own client, do the same rather
than paying for a run to tell you what subtraction can.