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.
| Status | Meaning |
|---|---|
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/billing. |
403 | The token isn't allowed to do this (e.g. a guest submitting a very large roster). |
404 | Unknown job or record id. |
5xx | Transient 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
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
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
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 field | Meaning |
|---|---|
model | The resolved model — gpt-5.6-terra. |
model_alias | The alias the app pins, gpt-terra. Assert on this if you care that the model has not moved under you. |
markup_bps | Platform markup in basis points — 1000. |
hold_credits | Worst-case cost, reserved for the duration of the run. You are charged only what the run actually uses. |
min_credits | The 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_enabled | true when guest runs are currently sponsored, i.e. free to try without signing in. |
The input object
| Input field | Type | Notes |
|---|---|---|
roster | string, required | The 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. |
bands | string, optional | The 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. |
context | string, optional | Company 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. |
compscan | string, optional | The 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_note | string, optional | Sent 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
/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 section | Rule |
|---|---|
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. |
## Findings | Free 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 risks | Same bullet rule. Only risks the data supports; no speculation about performance or intent. |
## Recommendations | Same bullet rule. Costed and sequenced where the data allows it. |
## Data gaps | Same bullet rule. This is where every unanswerable question lands. |
Consistency rules the app enforces on top of the shape:
| Verdict | What it requires |
|---|---|
Healthy | Both 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 attention | The 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 risk | At 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
/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.
| Event | Payload | Meaning |
|---|---|---|
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.