Org Pulse — API

A roster in, a grounded people report out — from your own code.

API tokens Open the app

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.

POST /guest GET /me POST /estimate POST /run POST /run-stream GET /jobs/{job_id}
Base URL: 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:

It will not:

A roster is personal data. The 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.

CodeStatusWhat it means
unauthorized401Missing, malformed or expired token. Mint a new one at POST /guest or sign in on the tokens page.
payment_required402Balance below min_credits. Check /estimate against /me before running — that is the whole point of the free estimate.
not_found404Usually a path that includes /apps/{slug}/. It does not exist. Use the bare routes above.
validation_error400The body was not the input object, or a field had the wrong type. Note the body is the input itself, not {"input":{…}}.
rate_limited429Back off and retry. Reuse the same Idempotency-Key so the retry cannot double-bill.
job_failed200Returned 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.Token
String 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"
}
JSON
def 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,
  }
end
function 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…"
}
FieldRequiredWhat it is
rosteryesThe 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.
focusyesOne of full report, headcount, attrition, org health. Weights the report body; the verdict and risks always consider everything.
as_ofyesYYYY-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.
contextnoFree 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.
orgscannoA 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());
A run whose balance sits between 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
end
function 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 tail
async 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());
  }
}
Two habits worth copying from the app: prefer the 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))
PY
import 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 rep
const 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?)) }
end
function 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.
RuleDetail
VERDICTFirst line. Exactly one of Steady, Watch, Act now — spelled and capitalised that way. There is no fourth value.
SCOPEOne short plain-text line, e.g. 31 people, 5 teams, as of 2026-08-01.
CONFIDENCEA bare integer 0–100. No percent sign, no range, no word.
SUMMARYOne to three sentences, may wrap over several lines, ends at the first blank line.
SectionsAll 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 sectionsThe 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.

GET /data/{key} PUT /data/{key} DELETE /data/{key} GET /data