Ratio Lens — API

Paste financial statements, get a structured analyst's read.

API tokens Open the app

Analyze financial statements from your own scripts

Send financial statement data — an income statement, balance sheet, cash flow statement, or all three — and get back one structured JSON object: a financial-health posture, the ratio table with a value and a one-sentence reading per metric, prioritized findings across profitability, liquidity, leverage, efficiency, valuation and earnings quality, each with its arithmetic written out, follow-up checks, and the focus areas to dig into first. The same analysis the web form runs, callable from a screening script, a diligence checklist, or a reporting pipeline. One paste in, one analysis 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/credits.
403The token isn't allowed to do this (e.g. a guest reviewing a very large configuration).
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.

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

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":"ratio-lens"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "ratio-lens"})["token"]
const { token } = await api("POST", "/guest", { slug: "ratio-lens" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "ratio-lens"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"ratio-lens"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "ratio-lens" })["token"]
$token = api("POST", "/guest", ["slug" => "ratio-lens"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "ratio-lens" });
var token = guest.GetProperty("token").GetString();

The app stores this browser's token under the localStorage key skillsafe_app_token:ratio-lens, 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 reviewing a large bundle.

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 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 whole reporting package in and want a ceiling before spending credits.

Input fieldTypeNotes
statementsstring, requiredThe financial statement data: labeled lines (Revenue: 412,800), CSV rows, or a multi-period table (newest period first), in any currency and scale — ($1.2B), (6,400) negatives and 4.2M suffixes all parse. This is the model's only evidence — nothing is fetched from filings or market data. Pastes longer than 100,000 characters are clipped middle-out, with a # [... clipped ...] line showing where. At least a couple of labeled figures are needed for an analysis.
perspectivestringgeneral | equity-investor | lender | operator | acquirer — whose decision the analysis serves; it sets what gets weighted (upside and quality for the investor, coverage and downside for the lender, levers for the operator, normalization for the acquirer).
concernstringgeneral | profitability | liquidity | leverage | efficiency | valuation — the analysis emphasis. It weights the findings and the summary, but it is emphasis and not exclusivity: a high-severity finding from another category is never suppressed.
contextstring, optionalExtra context: industry and business model, the currency and scale of the figures, known one-offs (an acquisition, an impairment, a 53rd week), and what decision the analysis feeds. Clipped at 20,000 characters.
prescan_factsobject, optionalWhat a client-side parser mechanically recognized: {"items": [], "ratios": [], "flags": []}. Each entry is {id, label}. Item ids look like item:revenue; ratio ids like ratio:current-ratio; flag ids are <check>:<name>negative-equity:set, current-below-1:set, thin-coverage:set, negative-margin:net, high-leverage:set, identity-gap:set, revenue-decline:set, earnings-quality:set, missing-core:…. Every flag id you send comes back in coverage_check. The web UI fills this from its own scan; API callers may omit the field or send the three empty arrays.
retry_notestring, optionalOnly set by the app's automatic reformat retry when a first reply was not valid JSON. Leave it out.
cat > statements.txt <<'TXT'
Revenue: 412,800
Cost of goods sold: 289,000
Operating income: 12,500
Interest expense: 8,900
Total current assets: 149,800
Total current liabilities: 168,200
Shareholders equity: 56,600
Net income: 2,700
TXT

jq -n --rawfile statements statements.txt \
  '{statements: $statements,
    perspective: "lender",
    concern: "general",
    context: "Mid-size home-goods retailer; USD thousands, FY2025; deciding on a working-capital facility.",
    prescan_facts: {units: [], flags: []}}' > input.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data.hold_credits'
STATEMENTS = """Revenue: 412,800
Cost of goods sold: 289,000
Operating income: 12,500
Interest expense: 8,900
Total current assets: 149,800
Total current liabilities: 168,200
Shareholders equity: 56,600
Net income: 2,700
"""

payload = {
    "statements": STATEMENTS,
    "perspective": "lender",
    "concern": "general",
    "context": "Mid-size home-goods retailer; USD thousands, FY2025; deciding on a working-capital facility.",
    "prescan_facts": {"units": [], "flags": []},
}

est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const statements = `Revenue: 412,800
Cost of goods sold: 289,000
Operating income: 12,500
Interest expense: 8,900
Total current assets: 149,800
Total current liabilities: 168,200
Shareholders equity: 56,600
Net income: 2,700
`;

const payload = {
  statements,
  perspective: "lender",
  concern: "general",
  context: "Mid-size home-goods retailer; USD thousands, FY2025; deciding on a working-capital facility.",
  prescan_facts: { units: [], flags: [] },
};

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
const statements = `Revenue: 412,800
Cost of goods sold: 289,000
Operating income: 12,500
Interest expense: 8,900
Total current assets: 149,800
Total current liabilities: 168,200
Shareholders equity: 56,600
Net income: 2,700
`

payload := map[string]any{
	"statements": statements,
	"perspective":  "lender",
	"concern":  "general",
	"context":  "Mid-size home-goods retailer; USD thousands, FY2025; deciding on a working-capital facility.",
	"prescan_facts": map[string]any{
		"units": []any{}, "flags": []any{},
	},
}

var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", payload, &est)
String statements = """
    Revenue: 412,800
    Cost of goods sold: 289,000
    Operating income: 12,500
    Interest expense: 8,900
    Total current assets: 149,800
    Total current liabilities: 168,200
    Shareholders equity: 56,600
    Net income: 2,700
    """;

String jsonPayload = """
    {"statements": %s,
     "perspective": "lender",
     "concern": "general",
     "context": "Mid-size home-goods retailer; USD thousands, FY2025; deciding on a working-capital facility.",
     "prescan_facts": {"units": [], "flags": []}}
    """.formatted(toJsonString(statements));

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
STATEMENTS = <<~TXT
  Revenue: 412,800
  Cost of goods sold: 289,000
  Operating income: 12,500
  Interest expense: 8,900
  Total current assets: 149,800
  Total current liabilities: 168,200
  Shareholders equity: 56,600
  Net income: 2,700
TXT

payload = { statements: STATEMENTS,
            perspective: "lender",
            concern: "general",
            context: "Mid-size home-goods retailer; USD thousands, FY2025; deciding on a working-capital facility.",
            prescan_facts: { units: [], flags: [] } }

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$statements = <<<'TXT'
Revenue: 412,800
Cost of goods sold: 289,000
Operating income: 12,500
Interest expense: 8,900
Total current assets: 149,800
Total current liabilities: 168,200
Shareholders equity: 56,600
Net income: 2,700
TXT;

$payload = [
    "statements"    => $statements,
    "perspective"         => "lender",
    "concern"       => "general",
    "context"       => "Mid-size home-goods retailer; USD thousands, FY2025; deciding on a working-capital facility.",
    "prescan_facts" => ["units" => [], "flags" => []],
];

$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var statements = """
    Revenue: 412,800
    Cost of goods sold: 289,000
    Operating income: 12,500
    Interest expense: 8,900
    Total current assets: 149,800
    Total current liabilities: 168,200
    Shareholders equity: 56,600
    Net income: 2,700
    """;

var payload = new {
    statements,
    perspective = "lender",
    concern = "general",
    context = "Mid-size home-goods retailer; USD thousands, FY2025; deciding on a working-capital facility.",
    prescan_facts = new {
        units = Array.Empty<object>(), flags = Array.Empty<object>(),
    },
};

var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits");

prescan_facts.flags is how you make the analysis answer for things you already know about. Send {"items": [{"id": "item:revenue", "label": "Revenue = 412.80M"}], "ratios": [], "flags": [{"id": "thin-coverage:set", "label": "interest coverage is 1.40x"}]} and every flag id comes back in coverage_check — addressed by a finding, or set aside with the reason. Nothing you flag is silently dropped, which makes it the field to assert on in a pipeline check.

Step 4 — Run the analysis and wait for the result

POST /run
GET /jobs/{job_id}

/run takes the same input as /estimate, places a credit hold and returns a job_id. Poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed (a run typically takes 30–90 s, since every finding carries its arithmetic written out). Always send an Idempotency-Key header so a network retry can't start a second, double-charged run. The analysis is in output — usually nested as output.output, and as a JSON string, so parse defensively. The samples below print the posture, the ratio table, the prioritized findings and the focus areas, then save the whole object to review.json.

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: rl-$(date +%s)" \
  -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

# unwrap the review once, then read it
echo "$JOB" | jq -r '.data.output.output' > review.json

jq -r '
  "\(.review_name) [\(.posture)]: \(.verdict)",
  "",
  "INVENTORY",
  (.inventory[] | "  \(.kind)/\(.name) in \(.group) - \(.role)"),
  "",
  "FINDINGS",
  (.findings[] | "  [\(.priority)] \(.id) \(.category) \(.resource): \(.problem)"),
  "",
  "QUICK WINS",
  (.quick_wins[] | "  - \(.)"),
  "",
  "FOCUS AREAS",
  (.focus_areas[] | "  \(.area) - \(.why)"),
  "",
  "COVERAGE",
  (.coverage_check[] | "  \(.id): \(if .addressed then "ok" else "SET ASIDE" end) - \(.note)")' \
  review.json

# fail the pipeline on anything critical
jq -e '[.findings[] | select(.priority == "critical")] | length == 0' review.json > /dev/null \
  || { echo "critical findings present"; exit 1; }
import time

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": "rl-001"})["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"))

raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
    raw = raw["output"]
review = json.loads(raw) if isinstance(raw, str) else raw

print(f'{review["review_name"]} [{review["posture"]}]: {review["verdict"]}')
for r in review["inventory"]:
    print(f'  {r["kind"]}/{r["name"]:<24} grp={r["group"] or "-":<12} {r["role"]}')
for f in review["findings"]:
    print(f'  [{f["priority"]:>8}] {f["id"]} {f["category"]} {f["resource"]}')
    print(f'      L:{f["likelihood"]}/S:{f["severity"]} {f["problem"]}')
    print(f'      fix: {f["fix"]}')
    if f["snippet"]:
        print("      snippet:", f["snippet"].splitlines()[0], "...")
for w in review["quick_wins"]:
    print("  win:", w)
for a in review["focus_areas"]:
    print(f'  focus {a["area"]} {a["finding_ids"]} - {a["why"]}')
for c in review["coverage_check"]:
    print(f'  {c["id"]}: {"ok" if c["addressed"] else "SET ASIDE"} - {c["note"]}')

with open("review.json", "w", encoding="utf-8") as fh:
    json.dump(review, fh, indent=2)

critical = [f for f in review["findings"] if f["priority"] == "critical"]
if critical:
    raise SystemExit(f"{len(critical)} critical finding(s)")
import { writeFileSync } from "node:fs";

const { job_id } = await api("POST", "/run", payload,
  { "Idempotency-Key": crypto.randomUUID() });

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 raw = job.output?.output ?? job.output;
const review = typeof raw === "string" ? JSON.parse(raw) : raw;

console.log(`${review.review_name} [${review.posture}]: ${review.verdict}`);
for (const r of review.inventory) {
  console.log(`  ${r.kind}/${r.name} (${r.group || "-"}): ${r.role}`);
}
for (const f of review.findings) {
  console.log(`  [${f.priority}] ${f.id} ${f.category} ${f.resource}`);
  console.log(`      L:${f.likelihood}/S:${f.severity} - ${f.fix}`);
}
for (const w of review.quick_wins) console.log(`  win: ${w}`);
for (const a of review.focus_areas) {
  console.log(`  focus ${a.area} (${a.finding_ids.join(", ")}): ${a.why}`);
}
for (const c of review.coverage_check) {
  console.log(`  ${c.id}: ${c.addressed ? "ok" : "SET ASIDE"} - ${c.note}`);
}

writeFileSync("review.json", JSON.stringify(review, null, 2));

const critical = review.findings.filter((f) => f.priority === "critical");
if (critical.length) process.exitCode = 1;
var started struct{ JobID string `json:"job_id"` }
if err := call("POST", "/run", payload, &started); err != nil {
	log.Fatal(err)
}

var job struct {
	Status string          `json:"status"`
	Error  string          `json:"error"`
	Output json.RawMessage `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)
}

// job.Output is {"output": "<json string>"} — unwrap, then unmarshal:
type Review struct {
	ReviewName string `json:"review_name"`
	Posture    string `json:"posture"`
	Verdict    string `json:"verdict"`
	ExecSummary string `json:"exec_summary"`
	Assumptions   []string `json:"assumptions"`
	OpenQuestions []string `json:"open_questions"`
	Inventory []struct {
		Kind, Name, Group, Role string
	} `json:"inventory"`
	Findings []struct {
		ID, Category, Severity, Likelihood, Priority string
		Resource, Problem, Impact, Fix, Snippet      string
	} `json:"findings"`
	CoverageCheck []struct {
		ID, Note  string
		Addressed bool
	} `json:"coverage_check"`
	QuickWins  []string `json:"quick_wins"`
	FocusAreas []struct {
		Area, Why  string
		FindingIDs []string `json:"finding_ids"`
	} `json:"focus_areas"`
	Summary string `json:"summary"`
}
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
var review Review
json.Unmarshal([]byte(wrapper.Output), &review)

fmt.Printf("%s [%s]: %s\n", review.ReviewName, review.Posture, review.Verdict)
for _, r := range review.Inventory {
	fmt.Printf("  %s/%s (%s): %s\n", r.Kind, r.Name, r.Group, r.Role)
}
for _, f := range review.Findings {
	fmt.Printf("  [%s] %s %s %s: %s\n", f.Priority, f.ID, f.Category, f.Resource, f.Problem)
}
for _, a := range review.FocusAreas {
	fmt.Printf("  focus %s %v: %s\n", a.Area, a.FindingIDs, a.Why)
}
os.WriteFile("review.json", []byte(wrapper.Output), 0o644)
String envelope = api("POST", "/run", jsonPayload);
String jobId = /* data.job_id via your JSON library */;

while (true) {
    String job = api("GET", "/jobs/" + jobId, null);
    String status = /* data.status */;
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}
// The review is at data.output.output as a JSON string — parse it again, then read
// review_name, posture, verdict, exec_summary, assumptions[], open_questions[],
// inventory[] (kind/name/group/role),
// findings[] (id/category/severity/likelihood/priority/resource/problem/impact/fix/snippet),
// coverage_check[] (id/addressed/note), quick_wins[],
// focus_areas[] (area/why/finding_ids[]) and summary.
// Finally keep the review on disk:
//   Files.writeString(Path.of("review.json"), reviewJson);
started = api("POST", "/run", payload)

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"

raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
review = raw.is_a?(String) ? JSON.parse(raw) : raw

puts "#{review["review_name"]} [#{review["posture"]}]: #{review["verdict"]}"
review["inventory"].each { |r| puts "  #{r["kind"]}/#{r["name"]} (#{r["group"]}): #{r["role"]}" }
review["findings"].each do |f|
  puts "  [#{f["priority"]}] #{f["id"]} #{f["category"]} #{f["resource"]}"
  puts "      L:#{f["likelihood"]}/S:#{f["severity"]} - #{f["fix"]}"
end
review["quick_wins"].each { |w| puts "  win: #{w}" }
review["focus_areas"].each { |a| puts "  focus #{a["area"]} #{a["finding_ids"].join(", ")}" }
review["coverage_check"].each { |c| puts "  #{c["id"]}: #{c["addressed"] ? "ok" : "SET ASIDE"}" }

File.write("review.json", JSON.pretty_generate(review))
exit 1 if review["findings"].any? { |f| f["priority"] == "critical" }
$started = api("POST", "/run", $payload);

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");
}

$raw = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
$review = is_string($raw) ? json_decode($raw, true) : $raw;

echo "{$review['review_name']} [{$review['posture']}]: {$review['verdict']}\n";
foreach ($review["inventory"] as $r) {
    echo "  {$r['kind']}/{$r['name']} ({$r['group']}): {$r['role']}\n";
}
foreach ($review["findings"] as $f) {
    echo "  [{$f['priority']}] {$f['id']} {$f['category']} {$f['resource']}\n";
    echo "      L:{$f['likelihood']}/S:{$f['severity']} - {$f['fix']}\n";
}
foreach ($review["quick_wins"] as $w) {
    echo "  win: $w\n";
}
foreach ($review["focus_areas"] as $a) {
    echo "  focus {$a['area']}: " . implode(", ", $a["finding_ids"]) . "\n";
}
foreach ($review["coverage_check"] as $c) {
    echo "  {$c['id']}: " . ($c["addressed"] ? "ok" : "SET ASIDE") . "\n";
}

file_put_contents("review.json", json_encode($review, JSON_PRETTY_PRINT));
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 rawText = job.GetProperty("output").GetProperty("output").GetString();
using var doc = JsonDocument.Parse(rawText!);
var review = doc.RootElement;

Console.WriteLine($"{review.GetProperty("review_name")} " +
                  $"[{review.GetProperty("posture")}]: {review.GetProperty("verdict")}");
foreach (var r in review.GetProperty("inventory").EnumerateArray())
{
    Console.WriteLine($"  {r.GetProperty("kind")}/{r.GetProperty("name")}: {r.GetProperty("role")}");
}
foreach (var f in review.GetProperty("findings").EnumerateArray())
{
    Console.WriteLine($"  [{f.GetProperty("priority")}] {f.GetProperty("id")} " +
                      $"{f.GetProperty("category")} {f.GetProperty("resource")} " +
                      $"(L:{f.GetProperty("likelihood")}/S:{f.GetProperty("severity")})");
}
foreach (var a in review.GetProperty("focus_areas").EnumerateArray())
{
    Console.WriteLine($"  focus {a.GetProperty("area")}: {a.GetProperty("why")}");
}

await File.WriteAllTextAsync("review.json", rawText!);

The model is asked for one JSON object and nothing else, but a stray code fence or preamble is always possible. Strip a leading ```json fence, take the text between the first { and the last }, and only then parse — that is what the app does before it falls back to a retry_note reformat run.

The analysis object — output schema

One JSON object, always the same shape. Every array is present, and the analysis is grounded in the pasted figures alone: every number cited comes from statements or is arithmetic on it, and something that is simply absent (no cash flow statement, no interest expense, no share count) is reported as a gap, against the closest real line or against (missing from the paste). Where the figures are ambiguous you get an entry in assumptions and, if the other reading would change the verdict, in open_questions. Expect four to twelve findings on a typical statement set — a genuinely healthy company may honestly yield two or three, and findings is never empty.

FieldTypeMeaning
review_namestringA short title naming the company or statement, taken from the paste's own naming — e.g. Meridian Retail FY25 — statement analysis.
posturestringsound | watch | stressed. See the table below.
verdictstringOne sentence justifying the posture and naming the single most important number behind it.
exec_summarystringTwo or three paragraphs, separated by blank lines, on the dominant themes across the set.
assumptionsstring[]Explicit readings chosen where the paste was ambiguous (scale, period, a line that could be two things). Read these first — a wrong assumption invalidates the findings built on it.
open_questionsstring[]Questions whose answers would change the verdict.
inventoryarray{kind, name, group, role} — the ratio table: every metric the pasted figures allow. kind is the category (profitability, liquidity, leverage, efficiency, quality, valuation, per-share); name is the metric; group is the computed value as a short string ("12.4%", "1.85x"); role is a one-sentence reading of that value in this company's context.
findingsarrayThe prioritized findings table — ids RL-001, RL-002, … in sequence, at least one entry. Columns are listed below.
coverage_checkarray{id, addressed, note} — one entry per prescan_facts.flags id you sent, each appearing exactly once. See the semantics below.
quick_winsstring[]Specific follow-up checks worth doing immediately — the footnote to pull, the schedule to request. May be empty.
focus_areasarray{area, why, finding_ids} — what to dig into first, one sentence tied to the analysis, and the finding ids that motivate it. Every id in finding_ids exists in findings.
summarystringClosing paragraph: the health story in plain language, and what would change it.

The three posture values:

postureWhat it means
soundThe figures hold up as pasted: real margins, liquidity that covers obligations, leverage the cash flows support, earnings backed by cash. Findings still exist, but they are monitoring points and refinements, not alarms. A genuinely healthy company lands here rather than having severity manufactured for it.
watchThe company functions, but named items need watching or explaining before relying on these statements — a margin trend, a working-capital build, leverage drifting up, an earnings-quality question the footnotes would settle.
stressedAt least one solvency-level fact as pasted: interest coverage near or below 1x, liquidity that cannot meet the year's obligations, negative equity from accumulated losses, or figures whose identity gap invalidates the picture.

Each entry in findings:

ColumnMeaning
idSequential RL-001, RL-002, … — the stable handle referenced from focus_areas[].finding_ids.
categoryprofitability | liquidity | leverage | efficiency | valuation | quality | data. Weighted by the concern you sent, but never restricted to it.
severitylow | medium | high — how bad it is when it bites.
likelihoodlow | medium | high — how likely it is to bite.
prioritycritical | high | medium | low — severity by likelihood. critical is reserved for solvency-level facts as pasted (coverage below 1x, liquidity that cannot meet obligations, an identity gap that invalidates the figures), so sort on this field and work top-down. This is also the field to gate a pipeline on.
resourceThe metric or line item this is about — e.g. interest coverage or accounts receivable — always something grounded in statements, or the literal (missing from the paste) when the finding is about a gap.
problemWhat the figures show, with the numbers, in this paste specifically.
impactWhat it means for the company and for the reader's decision.
fixWhat to do or check next — the follow-up that resolves or confirms the finding.
snippetThe arithmetic written out: formula, inputs as pasted, result. Empty string when a calculation would add nothing.

coverage_check semantics:

CaseWhat you get
Every flag id you sentEach prescan_facts.flags id appears in coverage_check exactly once. Nothing you flagged is silently dropped, which makes this the field to assert on in a pipeline check. Ids in prescan_facts.items and prescan_facts.ratios are not reconciled here — they ground the ratio table instead.
addressed: trueThe flag is covered by the analysis; note names the finding id that covers it.
addressed: falseThe flag was deliberately set aside; note gives the reason — a check that fired but is not a real problem for this company (a sub-1 current ratio in a negative working-capital business model, negative equity explained by buybacks in the context).
Nothing sentOmit prescan_facts, or send the three empty arrays, and coverage_check comes back empty. The rest of the analysis is unaffected.

A small, realistic result for the strained-retailer figures above, trimmed for length:

{
  "review_name": "Meridian Home & Garden FY2025 — statement analysis",
  "posture": "stressed",
  "verdict": "Interest coverage of 1.40x and a current ratio of 0.89x mean debt service and the
              year's obligations both depend on things going right; the coverage number decides
              the lending answer.",
  "exec_summary": "A retailer whose operating result barely clears its interest bill.

                   Operating income of 12,500 against interest expense of 8,900 leaves 1.40x
                   coverage — thin for a fixed-cost business. Current liabilities of 168,200
                   exceed current assets of 149,800, so near-term liquidity leans on inventory
                   turning and on the facility being renewed.",
  "assumptions": [
    "Figures are USD thousands, one fiscal year, as the context states.",
    "Shareholders equity of 56,600 is the stated book value; no preferred stock is listed."
  ],
  "open_questions": [
    "Is there undrawn revolver capacity outside these statements?",
    "How much of the 96,700 inventory is seasonal and how fast does it turn?"
  ],
  "inventory": [
    { "kind": "profitability", "name": "Operating margin", "group": "3.0%",
      "role": "Thin for retail; leaves little buffer over fixed interest and rent." },
    { "kind": "liquidity", "name": "Current ratio", "group": "0.89x",
      "role": "The year's obligations exceed the assets that fund them." },
    { "kind": "leverage", "name": "Interest coverage", "group": "1.40x",
      "role": "A 29% EBIT dip puts coverage below 1x." }
  ],
  "findings": [
    { "id": "RL-001", "category": "leverage",
      "severity": "high", "likelihood": "high", "priority": "critical",
      "resource": "interest coverage",
      "problem": "Operating income of 12,500 against interest expense of 8,900 gives 1.40x coverage.",
      "impact": "Most of the operating result services debt; a weak season or a rate reset
                 makes interest unpayable from operations, which is the lender's first question.",
      "fix": "Pull the debt footnote for maturities and covenant terms; model coverage at
              revenue -10% before extending the facility.",
      "snippet": "interest coverage = operating income / interest expense = 12,500 / 8,900 = 1.40x" },
    { "id": "RL-002", "category": "liquidity",
      "severity": "medium", "likelihood": "high", "priority": "high",
      "resource": "current ratio",
      "problem": "Current assets of 149,800 against current liabilities of 168,200 give 0.89x.",
      "impact": "Near-term obligations depend on inventory converting on schedule; any slowdown
                 forces the revolver exactly when coverage is thin.",
      "fix": "Check the inventory aging and the facility's renewal date.",
      "snippet": "current ratio = current assets / current liabilities = 149,800 / 168,200 = 0.89x" }
  ],
  "coverage_check": [
    { "id": "thin-coverage:set", "addressed": true, "note": "RL-001." },
    { "id": "current-below-1:set", "addressed": true, "note": "RL-002." }
  ],
  "quick_wins": [
    "Pull the debt footnote for maturities and covenants.",
    "Ask for the monthly inventory turn — 96,700 against 289,000 annual COGS is ~4 months on the shelf."
  ],
  "focus_areas": [
    { "area": "Debt service headroom",
      "why": "Coverage of 1.40x is the number that decides whether this company controls its own timeline.",
      "finding_ids": ["RL-001"] },
    { "area": "Working-capital cycle",
      "why": "The sub-1 current ratio is safe only if inventory turns as fast as management assumes.",
      "finding_ids": ["RL-002"] }
  ],
  "summary": "As pasted, a stressed credit: thin coverage stacked on negative working capital.
              The picture changes with evidence of undrawn liquidity or a margin recovery — and
              nothing here is investment advice, it is analysis of the figures as pasted."
}

This is AI-generated analysis of pasted figures, not investment advice or an audit: it sees only what you sent, never the filings, the footnotes or the market. Check assumptions and open_questions before you act on the rankings, verify the arithmetic against the filed statements, and keep a human analyst in the loop.

Step 5 — Stream the analysis as it is written

POST /run-stream

/run-stream takes exactly the same body as /run but answers with server-sent events, so you can show progress instead of a spinner — useful here because a full ratio table with readings makes for a long reply. This app's own progress panel is this endpoint. 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 reply, in order. Append it; the accumulated length is your only progress signal (the total is not known in advance). The app advances its step list by watching for the "review_name", "inventory", "findings", "coverage_check" and "focus_areas" keys as they arrive.
done{job_id, status, charged_credits, output}The final, authoritative result — read the analysis from output.output rather than trusting concatenated deltas, and the settled price from charged_credits.
error{code, message}Replaces done when the run fails.
# -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: rl-$(date +%s)" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"{\"review_name\":\"Meridian Home"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":612,"output":{"output":"{...}"}}
import json, requests

result = None
with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": "rl-001"},
    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 = json.loads(result["output"]["output"])          # authoritative
print("charged:", result["charged_credits"], "-", review["review_name"])
print("posture:", review["posture"])
for f in review["findings"]:
    print(f'  [{f["priority"]}] {f["id"]} {f["resource"]}: {f["problem"]}')
with open("review.json", "w", encoding="utf-8") as fh:
    json.dump(review, fh, indent=2)
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify(payload),
});

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

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") process.stdout.write(".");   // live progress
    if (name === "done") done = data;
    if (name === "error") throw new Error(data.message ?? "run failed");
  }
}

const review = JSON.parse(done.output.output);
console.log(`\n${done.charged_credits} credits - ${review.review_name} [${review.posture}]`);
for (const f of review.findings) console.log(`  [${f.priority}] ${f.id} ${f.resource}`);
writeFileSync("review.json", JSON.stringify(review, null, 2));
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", "pc-001")

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 JSON —
// unmarshal it into the Review struct from step 4, then write it to review.json.
// 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", "pc-001")
    .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 parse data.output.output again — it is a JSON string holding
// review_name, posture, verdict, inventory[], findings[], coverage_check[],
// quick_wins[], focus_areas[] and the rest.
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"] = "kc-001"
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 = JSON.parse(done["output"]["output"])
puts "\n#{done["charged_credits"]} credits - #{review["review_name"]} [#{review["posture"]}]"
review["findings"].each { |f| puts "  [#{f["priority"]}] #{f["id"]} #{f["resource"]}" }
File.write("review.json", JSON.pretty_generate(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: kc-001",
    ],
    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 = json_decode($done["output"]["output"], true);
echo "\n{$done['charged_credits']} credits - {$review['review_name']} [{$review['posture']}]\n";
foreach ($review["findings"] as $f) {
    echo "  [{$f['priority']}] {$f['id']} {$f['resource']}\n";
}
file_put_contents("review.json", json_encode($review, JSON_PRETTY_PRINT));
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "pc-001");

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 text = final.RootElement.GetProperty("output").GetProperty("output").GetString();
using var reviewDoc = JsonDocument.Parse(text!);
var review = reviewDoc.RootElement;
Console.WriteLine($"{review.GetProperty("review_name")} [{review.GetProperty("posture")}]");
foreach (var f in review.GetProperty("findings").EnumerateArray())
    Console.WriteLine($"  [{f.GetProperty("priority")}] {f.GetProperty("id")} {f.GetProperty("resource")}");
await File.WriteAllTextAsync("review.json", text!);

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.