Comp Check — API

Bands and roster in — placement, risks and fixes out, from your numbers only.

API tokens Open the app

Review the comp data from your own tools

Send two pasted tables — your compensation bands and your employee roster — and get back one compensation review: a verdict of Healthy, Needs attention or At risk, a confidence figure, the full findings with the band arithmetic shown, then band placement and outliers, retention risks, costed recommendations for the next cycle and the questions your data cannot answer. Every number in the reply comes from the tables you sent or arithmetic over them; market percentiles are never invented, they land under Data gaps. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can wire it to the HRIS export that produces the roster, run it nightly against the current band sheet, or gate a comp-cycle checklist on the verdict. 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 comp-check. There is no /apps/{slug}/ path segment — the slug is bound to the token when you mint it at POST /guest, and every later call just carries that token. 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. The review is produced by the gpt-terra model (which resolves to gpt-5.6-terra) at a markup of 1000 basis points. Estimates are free; runs are metered against your credit balance. There is a single run task — one pair of tables in, one review out, no follow-up calls and no session state to carry.

StatusMeaning
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/billing.
403The token isn't allowed to do this (e.g. a guest submitting a very large roster).
404Unknown job or record id.
5xxTransient platform error — retry with backoff.

Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend. Rosters carry names and salaries: keep the token out of your repository and treat the request body as the personnel data it is.

Step 0 — A tiny client

Every task below is a single HTTP call, so start with a short helper that adds the auth header, sends JSON and unwraps the data envelope. The later steps reuse it.

export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN"      # see step 1

# every call looks like:
#   curl -s "$API/..." -H "Authorization: Bearer $TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": ...} envelope
import json, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # see step 1 — read it from your shell environment in real code

def api(method, path, body=None, **headers):
    res = requests.request(method, API + path, json=body,
                           headers={"Authorization": f"Bearer {TOKEN}", **headers})
    payload = res.json()
    if not res.ok:
        raise RuntimeError(payload.get("error", {}).get("message", res.reason))
    return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — read it from your shell environment in real code

async function api(method, path, body, extraHeaders = {}) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
  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") // see step 1

func call(method, path string, body, out any) error {
	var buf bytes.Buffer
	if body != nil {
		json.NewEncoder(&buf).Encode(body)
	}
	req, _ := http.NewRequest(method, API+path, &buf)
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var env struct {
		Data  json.RawMessage `json:"data"`
		Error *struct{ Message string `json:"message"` } `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 {
		return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class SkillSafe {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String api(String method, String path, String jsonBody) throws Exception {
        var req = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json")
            .method(method, jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body());
        return res.body(); // envelope: {"data": …}
    }
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1

def api(method, path, body = nil)
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  req.body = body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1

function api(string $method, string $path, ?array $body = null): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
    ]);
    $payload = json_decode(curl_exec($ch), true);
    $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new Exception($payload["error"]["message"] ?? "HTTP $status");
    }
    return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;

static class SkillSafe
{
    const string Api = "https://api.skillsafe.ai/v1/app-api";
    static readonly HttpClient Http = new();

    static SkillSafe() =>
        Http.DefaultRequestHeaders.Authorization =
            new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1

    public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (body != null) req.Content = JsonContent.Create(body);
        var res = await Http.SendAsync(req);
        var json = await res.Content.ReadFromJsonAsync<JsonElement>();
        if (!res.IsSuccessStatusCode)
            throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

POST /guest

The body {"slug":"comp-check"} is where the app slug is declared; the token you get back is bound to this app, which is why no later URL mentions it. A guest token lets you check balances and estimate costs for free. For metered review runs billed to your own account, use your personal token: open the token page, sign in with SkillSafe, and press Copy shell export — it puts export SKILLSAFE_TOKEN="…" on your clipboard, which every example below reads. Treat the token like a password: it can spend your credits. For fully headless scripts, POST /guest mints a guest token with no browser involved.

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"comp-check"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "comp-check"})["token"]
const { token } = await api("POST", "/guest", { slug: "comp-check" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "comp-check"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"comp-check"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "comp-check" })["token"]
$token = api("POST", "/guest", ["slug" => "comp-check"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "comp-check" });
var token = guest.GetProperty("token").GetString();

The app stores this browser's token under the localStorage key skillsafe_app_token:comp-check, on the app's own origin. The token page reads and manages it for you — you never need to open developer tools.

Step 2 — Check who you are and your balance

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance. Check this before sending a long roster — the app compares the balance against the estimate before it will let the run start, and so should you.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
	SubjectType string `json:"subject_type"`
	Credits     int64  `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");

Step 3 — Estimate the cost

POST /estimate

Send exactly the input you would send to /run — the input object directly, not wrapped in {"input": …}. The response's hold_credits is the worst-case cost. Nothing is charged and no job is created, so estimating is free — useful when you are piping a few hundred roster rows in and want a ceiling before spending credits.

Response fieldMeaning
modelThe resolved model — gpt-5.6-terra.
model_aliasThe alias the app pins, gpt-terra. Assert on this if you care that the model has not moved under you.
markup_bpsPlatform markup in basis points — 1000.
hold_creditsWorst-case cost, reserved for the duration of the run. You are charged only what the run actually uses.
min_creditsThe floor: below this balance the run cannot start at all. Between min_credits and hold_credits a run starts but may be cut short, and the job then comes back with truncated: true.
sponsor_enabledtrue when guest runs are currently sponsored, i.e. free to try without signing in.

The input object

Input fieldTypeNotes
rosterstring, requiredThe pasted employee compensation table, verbatim: CSV, TSV or a markdown table, with whatever column names your sheet uses — name, role, level, location, base, bonus_target, equity_annual, start_date are all matched loosely. This and bands are the model's only evidence; no HRIS is read and no survey is consulted. If you have clipped a long roster, leave a marker line where the cut is — the web UI inserts a [... N middle rows … were omitted …] line and keeps both ends, and the model reports the omission under Data gaps rather than pretending those people do not exist.
bandsstring, optionalThe pasted compensation band table, verbatim, with columns like role, level, min, mid, max. Omit it and the review covers internal structure only — same-level spread, inversions, consistency — and names the missing bands as a data gap. Band placement and compa-ratios need this field.
contextstring, optionalCompany stage, headcount, the cycle you are planning, what worries you, and any benchmark figures you have bought yourself. Benchmarks supplied here are used, and the review says they were user-supplied; nothing is ever invented to fill the same slot.
compscanstring, optionalThe plain-text summary of the free in-browser band-math scan — detected columns, parsed headcounts, band matches, and the mechanical flags (below min, above max, compression, inversion, missing cells). It is a hint, not evidence: the model is told to recompute rather than trust it. API callers can simply omit this field; the review is unaffected apart from a small loss of parsing help.
retry_notestring, optionalSent only on the one automatic reformat retry, when a first reply did not match the output shape below. It restates the contract and asks for the reply again. Leave it out of your first call; add it only if you are implementing the same one-shot reformat retry yourself.

The samples below use the app's own worked example: a fictional Series B startup with bands for two roles and a thirteen-person roster carrying deliberate problems — a below-min long-tenure engineer, an above-max recent hire, a pay inversion, a missing base cell and compressed L4/L5 midpoints. The tables live in two files so the language samples stay readable; in your own code they come straight out of your sheet export.

cat > bands.csv <<'BANDS'
role,level,min,mid,max
Software Engineer,L3,140000,155000,170000
Software Engineer,L4,165000,185000,205000
Software Engineer,L5,180000,200000,220000
Software Engineer,L6,215000,240000,265000
Product Manager,L3,135000,150000,165000
Product Manager,L4,162000,180000,198000
Product Manager,L5,195000,216000,237000
BANDS

cat > roster.csv <<'ROSTER'
name,role,level,location,base,bonus_target,equity_annual,start_date
Maya Okonkwo-Reyes,Software Engineer,L3,Austin,148000,8%,40000,2024-06
Devon Ashgrove,Software Engineer,L3,Remote - US,152000,8%,42000,2023-09
Priya Balasundaram,Software Engineer,L4,San Francisco,158000,10%,55000,2021-03
Tomas Villanueva-Kirk,Software Engineer,L4,San Francisco,204000,10%,70000,2026-01
Ines Marchetti,Software Engineer,L4,Remote - US,186000,10%,58000,2023-11
Kwame Adjei-Boateng,Software Engineer,L5,Austin,196000,12%,80000,2022-08
Sunniva Halvorsen,Software Engineer,L5,San Francisco,232000,12%,95000,2026-02
Rafael Quintanilla,Software Engineer,L5,London,205000,12%,85000,2024-02
Hollis Grierson,Software Engineer,L6,San Francisco,248000,15%,130000,2022-01
Anneke Vosloo,Product Manager,L3,Remote - US,149000,10%,40000,2025-04
Bao-Tran Nguyen-Delacroix,Product Manager,L4,Austin,,12%,60000,2024-10
Sergei Kaltenbrunner,Product Manager,L4,London,176000,12%,62000,2023-05
Zuri Mbeki-Lindqvist,Product Manager,L5,San Francisco,221000,15%,100000,2021-11
ROSTER

cat > context.txt <<'CONTEXT'
We are a Series B startup, about 80 people, heading into our annual compensation
review cycle in the next six weeks. We did not buy market benchmark survey data
this year, so these bands were set internally about 18 months ago and have not
been refreshed. Engineering attrition is my main worry - we lost two senior
engineers last quarter and I suspect some of our longer-tenured folks have
fallen behind.
CONTEXT

# the input object goes on the wire directly — there is no {"input": ...} wrapper
jq -n --rawfile bands bands.csv --rawfile roster roster.csv --rawfile context context.txt \
  '{bands: $bands, roster: $roster, context: $context}' > input.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data | {model, model_alias, markup_bps, hold_credits, min_credits, sponsor_enabled}'
from pathlib import Path

payload = {
    "bands":   Path("bands.csv").read_text(encoding="utf-8"),
    "roster":  Path("roster.csv").read_text(encoding="utf-8"),
    "context": Path("context.txt").read_text(encoding="utf-8"),
    # "compscan" is the browser's band-math summary — omit it from API calls
}

est = api("POST", "/estimate", payload)            # the object itself, unwrapped
print(est["model"], est["model_alias"], est["markup_bps"], "bps")
print("worst case:", est["hold_credits"], "credits; floor:", est["min_credits"])

me = api("GET", "/me")
if me["credits"] < est["min_credits"]:
    raise SystemExit("not enough credits to start this run")
if me["credits"] < est["hold_credits"]:
    print("balance below the full reserve — the run may be cut short")
import { readFileSync } from "node:fs";

const read = (f) => readFileSync(f, "utf8");

const payload = {
  bands: read("bands.csv"),
  roster: read("roster.csv"),
  context: read("context.txt"),
  // "compscan" is the browser's band-math summary — omit it from API calls
};

const est = await api("POST", "/estimate", payload); // the object itself, unwrapped
console.log(est.model, est.model_alias, est.markup_bps, "bps");
console.log("worst case:", est.hold_credits, "credits; floor:", est.min_credits);

const me = await api("GET", "/me");
if (me.credits < est.min_credits) throw new Error("not enough credits to start this run");
if (me.credits < est.hold_credits) console.warn("balance below the full reserve — may be cut short");
read := func(name string) string {
	b, err := os.ReadFile(name)
	if err != nil {
		log.Fatal(err)
	}
	return string(b)
}

payload := map[string]any{
	"bands":   read("bands.csv"),
	"roster":  read("roster.csv"),
	"context": read("context.txt"),
	// "compscan" is the browser's band-math summary — omit it from API calls
}

var est struct {
	Model          string `json:"model"`
	ModelAlias     string `json:"model_alias"`
	MarkupBps      int    `json:"markup_bps"`
	HoldCredits    int64  `json:"hold_credits"`
	MinCredits     int64  `json:"min_credits"`
	SponsorEnabled bool   `json:"sponsor_enabled"`
}
if err := call("POST", "/estimate", payload, &est); err != nil {
	log.Fatal(err)
}
fmt.Printf("%s (%s) markup %d bps — reserve %d, floor %d\n",
	est.Model, est.ModelAlias, est.MarkupBps, est.HoldCredits, est.MinCredits)
import java.nio.file.Files;
import java.nio.file.Path;

String bands   = Files.readString(Path.of("bands.csv"));
String roster  = Files.readString(Path.of("roster.csv"));
String context = Files.readString(Path.of("context.txt"));

// Build the body with your JSON library — the input object goes on the wire
// directly, with no {"input": ...} wrapper. toJsonString() escapes a string.
String jsonPayload = """
    {"bands": %s, "roster": %s, "context": %s}
    """.formatted(toJsonString(bands), toJsonString(roster), toJsonString(context));

String envelope = api("POST", "/estimate", jsonPayload);
// data.model, data.model_alias, data.markup_bps,
// data.hold_credits, data.min_credits, data.sponsor_enabled
payload = {
  bands:   File.read("bands.csv"),
  roster:  File.read("roster.csv"),
  context: File.read("context.txt")
  # "compscan" is the browser's band-math summary — omit it from API calls
}

est = api("POST", "/estimate", payload)          # the object itself, unwrapped
puts "#{est["model"]} (#{est["model_alias"]}) markup #{est["markup_bps"]} bps"
puts "worst case: #{est["hold_credits"]} credits; floor: #{est["min_credits"]}"

me = api("GET", "/me")
abort "not enough credits to start this run" if me["credits"] < est["min_credits"]
$payload = [
    "bands"   => file_get_contents("bands.csv"),
    "roster"  => file_get_contents("roster.csv"),
    "context" => file_get_contents("context.txt"),
    // "compscan" is the browser's band-math summary — omit it from API calls
];

$est = api("POST", "/estimate", $payload);       // the object itself, unwrapped
echo "{$est['model']} ({$est['model_alias']}) markup {$est['markup_bps']} bps\n";
echo "worst case: {$est['hold_credits']} credits; floor: {$est['min_credits']}\n";

$me = api("GET", "/me");
if ($me["credits"] < $est["min_credits"]) {
    throw new Exception("not enough credits to start this run");
}
var payload = new {
    bands   = File.ReadAllText("bands.csv"),
    roster  = File.ReadAllText("roster.csv"),
    context = File.ReadAllText("context.txt"),
    // "compscan" is the browser's band-math summary — omit it from API calls
};

var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"{est.GetProperty("model")} ({est.GetProperty("model_alias")}) " +
                  $"markup {est.GetProperty("markup_bps")} bps");
Console.WriteLine($"reserve {est.GetProperty("hold_credits")}, " +
                  $"floor {est.GetProperty("min_credits")}");

var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
if (me.GetProperty("credits").GetInt64() < est.GetProperty("min_credits").GetInt64())
    throw new Exception("not enough credits to start this run");

hold_credits is a reserve, not a price: it is held for the duration of the run and only what the run actually uses is charged, which the job's charged_credits reports afterwards. Estimating with the exact body you intend to run is the point — a roster twice the size reserves roughly twice as much.

Step 4 — Run the review and wait for the result

POST /run
GET /jobs/{job_id}

/run takes the same body as /estimate — again the input object directly — places a credit hold and returns a job_id. Poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed; that is the exact path and cadence the app's own SDK uses in waitForJob. A run typically takes 40–120 s, since the reply carries the full findings plus four bullet sections. The terminal job object gives you status, output, charged_credits, truncated and error; the review text is at output.output as a plain string — not JSON, so there is nothing to parse beyond the tag lines and the headings.

Idempotency

/run and /run-stream both accept an Idempotency-Key header, and you should always send one: a network hiccup on the response of a paid call is exactly when you least want a blind retry to start a second run. Replaying a key the server has already seen returns the ORIGINAL job — the response carries deduped: true — and it does so even if the request body has changed. That is the safety property, and it is also the trap: a key derived from the input alone means a user who edits nothing and deliberately runs again is handed back the first job rather than a new one.

So the app derives the key as comp-check-<hash>-<nonce>: an FNV-1a hash over {bands, roster, context} for legibility, plus a per-submission nonce. It is constant for the duration of one submission, so a dropped connection can never double-bill; it is different on the next deliberate run, so a re-run is a real run. The one-shot reformat retry sends comp-check-<hash>-<nonce>-reformat — derived from that submission's key but distinct from it, so the retry cannot be answered with the malformed reply it exists to replace. That retry is a second billed run; the first is not refunded. Do the same in your own client: one key per attempt you actually intend to pay for, reused only when re-sending the identical request after a transport failure.

KEY="comp-check-$(shasum -a 256 input.json | cut -c1-8)-$(date +%s)"

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @input.json | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done

[ "$STATUS" = "failed" ] && { echo "$JOB" | jq -r '.data.error'; exit 1; }

# the review is plain text at data.output.output — write it straight out
echo "$JOB" | jq -r '.data.output.output' > review.md
echo "$JOB" | jq -r '"charged \(.data.charged_credits) credits, truncated=\(.data.truncated)"'

VERDICT=$(head -n 1 review.md | sed 's/^VERDICT:[[:space:]]*//')
echo "verdict: $VERDICT"
grep -n '^## ' review.md

# gate the cycle checklist on the verdict
[ "$VERDICT" = "Healthy" ] || { echo "comp needs work before the cycle"; exit 1; }
import re, time, uuid

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": f"comp-check-{uuid.uuid4().hex[:8]}"})["job_id"]

while True:
    job = api("GET", f"/jobs/{job_id}")
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(1.5)

if job["status"] == "failed":
    raise RuntimeError(job.get("error", "run failed"))

review = job["output"]["output"]            # plain text, not JSON
if job.get("truncated"):
    print("warning: the run was cut short by the credit reserve")

verdict = re.search(r"^VERDICT:\s*(.+)$", review, re.M).group(1).strip()
scope = re.search(r"^SCOPE:\s*(.+)$", review, re.M).group(1).strip()
confidence = int(re.search(r"^CONFIDENCE:\s*(\d{1,3})\s*$", review, re.M).group(1))
print(f"{verdict} — {scope} (confidence {confidence})")
print("charged:", job["charged_credits"], "credits")

sections = dict(re.findall(r"^## (.+)\n([\s\S]*?)(?=\n## |\Z)", review, re.M))
for name in ("Band placement and outliers", "Retention risks",
             "Recommendations", "Data gaps"):
    bullets = [b.strip() for b in re.findall(r"^- (.+)$", sections[name], re.M)]
    if bullets == ["None."]:
        bullets = []
    print(f"{name}: {len(bullets)}")
    for b in bullets:
        print("   -", b)

with open("review.md", "w", encoding="utf-8") as fh:
    fh.write(review)

if verdict != "Healthy":
    raise SystemExit(f"{verdict} — review before the cycle closes")
import { writeFileSync } from "node:fs";

const { job_id } = await api("POST", "/run", payload,
  { "Idempotency-Key": `comp-check-${crypto.randomUUID().slice(0, 8)}` });

let job;
do {
  await new Promise((r) => setTimeout(r, 1500));
  job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");

if (job.status === "failed") throw new Error(job.error ?? "run failed");

const review = job.output.output;            // plain text, not JSON
if (job.truncated) console.warn("the run was cut short by the credit reserve");

const tag = (name) => new RegExp(`^${name}:\\s*(.+)$`, "m").exec(review)?.[1].trim();
console.log(`${tag("VERDICT")} — ${tag("SCOPE")} (confidence ${tag("CONFIDENCE")})`);
console.log("charged:", job.charged_credits, "credits");

const sections = Object.fromEntries(
  [...review.matchAll(/^## (.+)\n([\s\S]*?)(?=\n## |$)/gm)].map((m) => [m[1], m[2]]),
);
for (const name of ["Band placement and outliers", "Retention risks",
                    "Recommendations", "Data gaps"]) {
  let bullets = [...sections[name].matchAll(/^- (.+)$/gm)].map((m) => m[1].trim());
  if (bullets.length === 1 && /^none\.?$/i.test(bullets[0])) bullets = [];
  console.log(`${name}: ${bullets.length}`);
  for (const b of bullets) console.log("   -", b);
}

writeFileSync("review.md", review);
if (tag("VERDICT") !== "Healthy") process.exitCode = 1;
// POST /run with an Idempotency-Key needs a header, so build the request by hand
// (or extend call() to take one) — then poll /jobs/{id} exactly as the SDK does.
var started struct{ JobID string `json:"job_id"` }
if err := callWithKey("POST", "/run", payload, "comp-check-3f2a1b7c-mf4k2p1", &started); err != nil {
	log.Fatal(err)
}

var job struct {
	Status         string `json:"status"`
	Error          string `json:"error"`
	ChargedCredits int64  `json:"charged_credits"`
	Truncated      bool   `json:"truncated"`
	Output         struct {
		Output string `json:"output"` // the review, as plain text
	} `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
		log.Fatal(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(1500 * time.Millisecond)
}
if job.Status == "failed" {
	log.Fatal(job.Error)
}

review := job.Output.Output
verdict := regexp.MustCompile(`(?m)^VERDICT:\s*(.+)$`).FindStringSubmatch(review)[1]
scope := regexp.MustCompile(`(?m)^SCOPE:\s*(.+)$`).FindStringSubmatch(review)[1]
fmt.Printf("%s — %s (charged %d credits, truncated=%v)\n",
	strings.TrimSpace(verdict), strings.TrimSpace(scope), job.ChargedCredits, job.Truncated)

for _, m := range regexp.MustCompile(`(?m)^## (.+)$`).FindAllStringSubmatch(review, -1) {
	fmt.Println("section:", m[1])
}
os.WriteFile("review.md", []byte(review), 0o600)
// Add the Idempotency-Key header to the /run request (extend api() with a
// headers map, or build the HttpRequest inline).
String envelope = api("POST", "/run", jsonPayload);   // + Idempotency-Key
String jobId = /* data.job_id via your JSON library */;

String job;
String status;
while (true) {
    job = api("GET", "/jobs/" + jobId, null);
    status = /* data.status */;
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}
// data.output.output is the review as PLAIN TEXT — no JSON parse needed.
// data.charged_credits is what you actually paid; data.truncated is true when
// the reserve cut the reply short; data.error carries the failure message.
//
// String review = /* data.output.output */;
// Matcher m = Pattern.compile("^VERDICT:\\s*(.+)$", Pattern.MULTILINE).matcher(review);
// The five "## " headings — Findings, Band placement and outliers, Retention
// risks, Recommendations, Data gaps — split the body; the last four hold
// "- " bullets, or the single bullet "- None.".
// Files.writeString(Path.of("review.md"), review);
started = api("POST", "/run", payload)   # add the Idempotency-Key header here

job = nil
loop do
  job = api("GET", "/jobs/#{started["job_id"]}")
  break if %w[succeeded failed].include?(job["status"])
  sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"

review = job["output"]["output"]         # plain text, not JSON
warn "the run was cut short by the credit reserve" if job["truncated"]

verdict = review[/^VERDICT:\s*(.+)$/, 1].strip
scope   = review[/^SCOPE:\s*(.+)$/, 1].strip
puts "#{verdict} — #{scope} (charged #{job["charged_credits"]} credits)"

sections = review.scan(/^## (.+)\n(.*?)(?=\n## |\z)/m).to_h
["Band placement and outliers", "Retention risks",
 "Recommendations", "Data gaps"].each do |name|
  bullets = sections[name].to_s.scan(/^- (.+)$/).flatten.map(&:strip)
  bullets = [] if bullets.length == 1 && bullets[0] =~ /\Anone\.?\z/i
  puts "#{name}: #{bullets.length}"
  bullets.each { |b| puts "   - #{b}" }
end

File.write("review.md", review)
exit 1 unless verdict == "Healthy"
$started = api("POST", "/run", $payload);   // add the Idempotency-Key header here

do {
    sleep(2);
    $job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));

if ($job["status"] === "failed") {
    throw new Exception($job["error"] ?? "run failed");
}

$review = $job["output"]["output"];          // plain text, not JSON
if (!empty($job["truncated"])) {
    fwrite(STDERR, "the run was cut short by the credit reserve\n");
}

preg_match('/^VERDICT:\s*(.+)$/m', $review, $v);
preg_match('/^SCOPE:\s*(.+)$/m', $review, $s);
echo trim($v[1]) . " — " . trim($s[1]) .
     " (charged {$job['charged_credits']} credits)\n";

preg_match_all('/^## (.+)\n(.*?)(?=\n## |\z)/ms', $review, $secs, PREG_SET_ORDER);
foreach ($secs as $sec) {
    if ($sec[1] === "Findings") { continue; }
    preg_match_all('/^- (.+)$/m', $sec[2], $bs);
    $bullets = array_map("trim", $bs[1]);
    if (count($bullets) === 1 && preg_match('/^none\.?$/i', $bullets[0])) {
        $bullets = [];
    }
    echo "{$sec[1]}: " . count($bullets) . "\n";
    foreach ($bullets as $b) { echo "   - $b\n"; }
}

file_put_contents("review.md", $review);
if (trim($v[1]) !== "Healthy") { exit(1); }
using System.Text.RegularExpressions;

var runReq = new HttpRequestMessage(HttpMethod.Post, "/run");
// send the same payload with an Idempotency-Key header:
//   runReq.Headers.Add("Idempotency-Key", $"comp-check-{hash}-{nonce}");
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload);
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
while (true)
{
    job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
    var status = job.GetProperty("status").GetString();
    if (status is "succeeded" or "failed") break;
    await Task.Delay(1500);
}

var review = job.GetProperty("output").GetProperty("output").GetString()!; // plain text
var verdict = Regex.Match(review, "^VERDICT:\\s*(.+)$", RegexOptions.Multiline).Groups[1].Value.Trim();
var scope = Regex.Match(review, "^SCOPE:\\s*(.+)$", RegexOptions.Multiline).Groups[1].Value.Trim();
Console.WriteLine($"{verdict} — {scope} " +
                  $"(charged {job.GetProperty("charged_credits")} credits)");

foreach (Match m in Regex.Matches(review, "^## (.+)$", RegexOptions.Multiline))
    Console.WriteLine("section: " + m.Groups[1].Value);

await File.WriteAllTextAsync("review.md", review);
if (verdict != "Healthy") Environment.ExitCode = 1;

The model is asked for the bare tagged text with no code fence, but a stray ```markdown wrapper is always possible. Strip an outer fence before you parse the first line — that is what the app does before it falls back to a retry_note reformat run.

The reply — output contract

The reply is plain text, not JSON, in exactly this shape. Four tag lines, then five level-2 sections in this order and no others. The app enforces every rule here on the way in; a reply that breaks one is discarded and retried once with the contract spelled out in retry_note.

VERDICT: Healthy | Needs attention | At risk
SCOPE: Engineering and PM, 13 people
CONFIDENCE: 72
SUMMARY: One to three sentences with the headline of the review. It may wrap
over several lines, and it ends at the first blank line.

## Findings
The full analysis in markdown — band structure first, then placement with the
compa-ratio arithmetic shown, then internal consistency, then whatever the
context adds.

### Band structure
#### L4 to L5
Only ### and #### headings appear inside Findings, never # or ##, and there
are no tables anywhere — bullet lists instead.

## Band placement and outliers
- One bullet per person: name, level, base against band, the gap in dollars.

## Retention risks
- Who could leave over pay, and why the pasted data says so.

## Recommendations
- Specific and costed, sequenced: under-min fixes first, then structure.

## Data gaps
- What the pasted data cannot answer.
Line or sectionRule
VERDICT:The first line. Its value is exactly one of Healthy, Needs attention, At risk — spelled and capitalized that way. There is no fourth value.
SCOPE:One short plain line naming what was reviewed, e.g. Engineering and PM, 13 people.
CONFIDENCE:A bare integer 0–100. No percent sign, no range, no word. It is confidence that the review is correct and complete as written — high when the tables parsed cleanly and the bands cover the roster, lower when columns were ambiguous, the roster was clipped, or many rows had no matching band.
SUMMARY:One to three sentences leading with the sharpest fact and its numbers. It may wrap over several lines and ends at the first blank line.
## FindingsFree markdown body. Headings inside it are ### and #### only — never # or ## — and there are no tables. Figures appear in backticks: $118,000, compa-ratio 0.87.
## Band placement and outliers"- " bullets only, one per person, or the single bullet - None.
## Retention risksSame bullet rule. Only risks the data supports; no speculation about performance or intent.
## RecommendationsSame bullet rule. Costed and sequenced where the data allows it.
## Data gapsSame bullet rule. This is where every unanswerable question lands.

Consistency rules the app enforces on top of the shape:

VerdictWhat it requires
HealthyBoth Band placement and outliers and Retention risks must be empty (- None.). A single outlier or risk bullet forbids Healthy — there is no "minor issue" qualifier that gets round it.
Needs attentionThe structure basically works but specific placements or the band geometry need fixing next cycle — someone under min, someone over max, compressed adjacent bands, an inversion, inconsistent equity at one level.
At riskAt least one Retention risks bullet is required. The data shows a pattern likely to cost people or money if the next cycle does not address it.

The web UI does not reject a reply that satisfies the shape but breaks a consistency rule — it renders it with a visible note telling the reader not to trust the verdict line. If you are automating on the verdict, apply the same two checks yourself: count the bullets in those two sections and compare them against the verdict before you act on it.

Grounding

Every number in the reply comes from bands, roster, context or arithmetic over them. The model does not invent market percentiles, benchmark figures, survey data or "typical" pay for a role or city; it does not assess location pay against what those markets actually pay, only against the other locations in your own paste and your own bands; it does not guess an empty cell. Benchmark numbers you supply in context are used, and are identified as user-supplied. When the question you are really asking needs data you did not send — "are we competitive?" — the answer is a Data gaps bullet, never an estimate. That is the single most important property to rely on when wiring this into a pay decision.

A short, realistic reply for the example above (findings abbreviated):

VERDICT: At risk
SCOPE: Engineering and PM, 13 people, 7 bands
CONFIDENCE: 78
SUMMARY: Two of nine engineers sit outside band and the L4-L5 midpoints are
only 8% apart, so a promotion into L5 is worth almost nothing. One L4 engineer
with nearly five years of tenure is `$7,000` below her band minimum.

## Findings

### Band structure
The Software Engineer ladder progresses `$155,000` to `$185,000` to `$200,000`
to `$240,000`. The L4 to L5 step is `8.1%` (`$185,000` to `$200,000`), against
`19.4%` for L3 to L4 and `20.0%` for L5 to L6 - the ladder pinches at exactly
the level most people are promoted into.

### Placement
Priya Balasundaram, L4, base `$158,000` against an L4 minimum of `$165,000`:
`$7,000` under, compa-ratio `0.85`. Tomas Villanueva-Kirk, hired January 2026
at `$204,000`, sits at compa-ratio `1.10` in the same band - a `$46,000` spread
inside one level, with the newer hire ahead.

### Internal consistency
Rafael Quintanilla (L5, `$205,000`) is paid above Sunniva Halvorsen's band
placement but below Tomas at L4 in dollar terms; the L4/L5 overlap of
`$165,000`-`$205,000` against `$180,000`-`$220,000` makes this structural
rather than a one-off decision.

## Band placement and outliers
- Priya Balasundaram, Software Engineer L4: base `$158,000` against an L4
  minimum of `$165,000` - `$7,000` below min, compa-ratio `0.85`.
- Tomas Villanueva-Kirk, Software Engineer L4: base `$204,000`, compa-ratio
  `1.10`, `$1,000` under the L4 maximum after ten months.
- Bao-Tran Nguyen-Delacroix, Product Manager L4: no base figure in the roster,
  so no placement could be computed.

## Retention risks
- Priya Balasundaram: below band minimum with the longest tenure in the L4
  cohort (March 2021) and `$46,000` behind a hire from January 2026 in the
  same band. This is the classic stale-comp departure.
- The L4 to L5 pinch means a promotion is worth about `$15,000` at midpoint;
  senior L4 engineers have little financial reason to stay for it.

## Recommendations
- Raise Priya Balasundaram to at least the L4 minimum, `+$7,000` to
  `$165,000`; to reach midpoint parity for tenure, `+$27,000` to `$185,000`.
- Re-anchor the L5 midpoint at least 15% above L4 - `$213,000` or higher -
  before the cycle opens, or the promotion budget buys nothing.
- Decide explicitly whether Tomas Villanueva-Kirk's `$204,000` prices the
  band or the band prices him; leaving it undecided repeats it at the next hire.
- Fill the missing base for Bao-Tran Nguyen-Delacroix before the cycle closes.

## Data gaps
- No market benchmark data was supplied, so this review cannot say whether any
  of these bands are competitive - only whether they are internally coherent.
- Austin, San Francisco, London and Remote - US appear in the roster with no
  location policy in the context, so no geographic differential was assessed.
- Bao-Tran Nguyen-Delacroix has no base salary in the roster.
- Bonus targets and equity are present but no vesting schedule or refresh
  policy was supplied, so total compensation was not modelled.

This is an AI-generated review of pasted numbers, not a compensation decision: it sees only the tables you sent, has no market data, and gives no legal advice on pay equity. Read Data gaps before you read anything else, check the arithmetic in Findings against your own sheet, and let a human own the outcome.

Step 5 — Stream the review as it is written

POST /run-stream

/run-stream takes exactly the same body and the same Idempotency-Key header as /run, but answers with server-sent events, so you can show progress instead of a spinner — useful here because a full review runs to several thousand characters. This app's own progress panel is this endpoint, and it advances its step list by watching for the ## Findings, ## Band placement and outliers and ## Data gaps headings as they arrive. Events are separated by a blank line; each has an event: line and a data: line carrying JSON.

EventPayloadMeaning
job{job_id, status}Sent once, when the job is accepted — show "starting".
delta{text}A chunk of the review, in order. Append it; the accumulated text is your only progress signal, and the section headings inside it are the honest milestones.
done{job_id, status, charged_credits, truncated, output}The final, authoritative result — read the review from output.output rather than trusting concatenated deltas, since the tail of a stream can drop.
error{code, message}Replaces done when the run fails. Whatever deltas already arrived are still worth parsing — the app renders the sections that made it rather than discarding paid-for work.
# -N disables buffering so events print as they arrive
curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: comp-check-3f2a1b7c-mf4k2p1" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"VERDICT: At risk\nSCOPE: Engineering and PM"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":612,
#        "truncated":false,"output":{"output":"VERDICT: At risk\n..."}}
import json, re, requests

result = None
with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": "comp-check-3f2a1b7c-mf4k2p1"},
    json=payload,
    stream=True,
) as r:
    r.raise_for_status()
    event = None
    for line in r.iter_lines(decode_unicode=True):
        if not line:
            continue
        if line.startswith("event:"):
            event = line[len("event:"):].strip()
        elif line.startswith("data:"):
            data = json.loads(line[len("data:"):].strip())
            if event == "delta":
                print(".", end="", flush=True)          # live progress
            elif event == "done":
                result = data
            elif event == "error":
                raise RuntimeError(data.get("message", "run failed"))

review = result["output"]["output"]                     # authoritative
print("\ncharged:", result["charged_credits"], "credits")
print(re.search(r"^VERDICT:\s*(.+)$", review, re.M).group(1))
with open("review.md", "w", encoding="utf-8") as fh:
    fh.write(review)
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "comp-check-3f2a1b7c-mf4k2p1",
  },
  body: JSON.stringify(payload),
});

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", done = null, seen = "";

for (;;) {
  const chunk = await reader.read();
  if (chunk.done) break;
  buf += decoder.decode(chunk.value, { stream: true });
  const frames = buf.split("\n\n");
  buf = frames.pop();
  for (const frame of frames) {
    const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
    const body = /^data:\s*(.+)$/m.exec(frame)?.[1];
    if (!name || !body) continue;
    const data = JSON.parse(body);
    if (name === "delta") { seen += data.text; process.stdout.write("."); }
    if (name === "done") done = data;
    if (name === "error") throw new Error(data.message ?? "run failed");
  }
}

const review = done.output.output;   // trust this, not the concatenated deltas
console.log(`\n${done.charged_credits} credits`);
console.log(/^VERDICT:\s*(.+)$/m.exec(review)[1]);
writeFileSync("review.md", review);
body, _ := json.Marshal(payload)
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", "comp-check-3f2a1b7c-mf4k2p1")

res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()

var event string
var final map[string]any
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
	case strings.HasPrefix(line, "data:"):
		var data map[string]any
		json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
		switch event {
		case "delta":
			fmt.Print(".") // live progress
		case "done":
			final = data
		case "error":
			log.Fatal(data["message"])
		}
	}
}
// final["output"].(map[string]any)["output"].(string) is the review text —
// write it to review.md and read the VERDICT/SCOPE/CONFIDENCE lines as in step 4.
// Java 17+ — read the stream line by line instead of buffering the body.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "comp-check-3f2a1b7c-mf4k2p1")
    .POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
    .build();

var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
    if (line.startsWith("event:")) {
        event = line.substring(6).trim();
    } else if (line.startsWith("data:")) {
        String data = line.substring(5).trim();
        if ("delta".equals(event)) System.out.print(".");   // live progress
        else if ("done".equals(event)) done = data;
        else if ("error".equals(event)) throw new RuntimeException(data);
    }
}
// parse `done`, then read data.output.output — a plain-text review whose first
// line is VERDICT:, followed by SCOPE:, CONFIDENCE:, SUMMARY: and the five
// "## " sections. data.charged_credits is the settled price.
require "net/http"
require "json"

uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "comp-check-3f2a1b7c-mf4k2p1"
req.body = payload.to_json

event = nil
done = 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 = line.strip
        if line.start_with?("event:")
          event = line.delete_prefix("event:").strip
        elsif line.start_with?("data:")
          data = JSON.parse(line.delete_prefix("data:").strip)
          case event
          when "delta" then print "."           # live progress
          when "done"  then done = data
          when "error" then raise (data["message"] || "run failed")
          end
        end
      end
    end
  end
end

review = done["output"]["output"]
puts "\n#{done["charged_credits"]} credits - #{review[/^VERDICT:\s*(.+)$/, 1]}"
File.write("review.md", review)
$event = null;
$done  = null;

$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Idempotency-Key: comp-check-3f2a1b7c-mf4k2p1",
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done) {
        foreach (explode("\n", $chunk) as $line) {
            $line = trim($line);
            if (str_starts_with($line, "event:")) {
                $event = trim(substr($line, 6));
            } elseif (str_starts_with($line, "data:")) {
                $data = json_decode(trim(substr($line, 5)), true);
                if ($event === "delta") { echo "."; }        // live progress
                elseif ($event === "done") { $done = $data; }
                elseif ($event === "error") { throw new Exception($data["message"] ?? "run failed"); }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$review = $done["output"]["output"];
preg_match('/^VERDICT:\s*(.+)$/m', $review, $v);
echo "\n{$done['charged_credits']} credits - " . trim($v[1]) . "\n";
file_put_contents("review.md", $review);
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "comp-check-3f2a1b7c-mf4k2p1");

using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());

string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
    if (line.StartsWith("event:")) evt = line[6..].Trim();
    else if (line.StartsWith("data:"))
    {
        var data = line[5..].Trim();
        if (evt == "delta") Console.Write(".");            // live progress
        else if (evt == "done") done = data;
        else if (evt == "error") throw new Exception(data);
    }
}

using var final = JsonDocument.Parse(done!);
var review = final.RootElement.GetProperty("output").GetProperty("output").GetString()!;
Console.WriteLine();
Console.WriteLine(final.RootElement.GetProperty("charged_credits") + " credits");
Console.WriteLine(Regex.Match(review, "^VERDICT:\\s*(.+)$", RegexOptions.Multiline).Groups[1].Value);
await File.WriteAllTextAsync("review.md", review);

In a browser, the native EventSource only speaks GET, and this endpoint is a POST — read the fetch response body incrementally, as the JavaScript sample above does. On an idempotent replay the server may answer with a plain JSON envelope instead of an event stream; check the Content-Type before you start parsing frames.