OpenAPI Studio — API

Paste the API source, get an OpenAPI 3.1 spec and a contract review.

API tokens Open the app

Generate an OpenAPI 3.1 spec from your own tools

Send your API source — route and handler code from any framework, an existing OpenAPI/Swagger document, or plain design notes — and get back one JSON object: a complete OpenAPI 3.1 specification for that API, a ship-readiness posture, the inventory of every operation with its operationId and role, prioritized findings across completeness, correctness, consistency, security, docs quality, versioning and hygiene, each with a corrected spec fragment, quick wins, and the focus areas to work through first. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can regenerate the contract on every pull request that touches routes/, diff the spec in CI, or gate a merge on the findings. 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 openapi-studio. 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 spec and review are produced by the gpt-terra model. Estimates are free; runs are metered against your credit balance. There is a single run task — one paste of API source in, one spec and review out, no follow-up calls and no session state to carry.

StatusMeaning
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/credits.
403The token isn't allowed to do this (e.g. a guest submitting a very large paste).
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":"openapi-studio"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "openapi-studio"})["token"]
const { token } = await api("POST", "/guest", { slug: "openapi-studio" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "openapi-studio"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"openapi-studio"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "openapi-studio" })["token"]
$token = api("POST", "/guest", ["slug" => "openapi-studio"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "openapi-studio" });
var token = guest.GetProperty("token").GetString();

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

Step 2 — Check who you are and your balance

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance. Check this before sending a large paste.

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 routes directory in and want a ceiling before spending credits.

Input fieldTypeNotes
source_textstring, requiredThe pasted API source. Route/handler code in any language or framework (Express, Fastify, Koa, FastAPI, Flask, Django, Rails, Spring, Go chi/gin/net-http, …), an existing OpenAPI or Swagger document in YAML or JSON, or prose design notes. A single file, or several each introduced by a header line of the form # === file: routes/users.js ===. This is the model's only evidence — nothing is executed and no repository is inspected. Input longer than 80,000 characters is clipped middle-out, with a # [... clipped ...] comment showing where. At least 30 characters are needed for a run.
input_kindstringcode | spec | notes | unknown — it changes the job: code derives the contract from the handlers, spec reviews and upgrades the existing document (keeping its intent, fixing its defects, emitting it as OpenAPI 3.1, including a Swagger 2.0 migration), notes designs the contract the prose describes, and unknown lets the model decide from the paste and say so in assumptions.
emphasisstringgeneral | completeness | consistency | security | docs-quality — the review 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: what the API serves, its real base URL, the auth model, the versioning policy, who the consumers are, and any quirk you have already chosen to accept. Clipped at 20,000 characters.
prescan_factsobject, optionalWhat a client-side scanner mechanically matched in the text: {"routes": [], "flags": []}. Each entry is {id, label}. Route ids look like file:routes-users-js for a file and route:routes-users-js:get-users-id for a parsed operation; flag ids are <check>:<where>verb-in-path:routes-js:post-createuser, dup-route:routes-js:get-users, param-style-mixed:routes-js, casing-mixed:routes-js, trailing-slash-mixed:routes-js, no-version:routes-js, spec-old-version:openapi-yaml, spec-no-operationid:openapi-yaml, spec-no-descriptions:openapi-yaml, spec-no-4xx:openapi-yaml, spec-no-security:openapi-yaml, spec-no-servers:openapi-yaml. 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 two 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 > routes.js <<'ROUTES'
const express = require("express");
const app = express();

app.get("/users/:id", async (req, res) => {
  res.json(await db.users.find(req.params.id));
});

app.post("/createUser", async (req, res) => {
  res.json(await db.users.insert(req.body));
});
ROUTES

# to send several files at once, concatenate them with header lines:
#   { echo "# === file: routes.js ==="; cat routes.js;
#     echo "# === file: orders.js ==="; cat orders.js; } > bundle.txt

jq -n --rawfile src routes.js \
  '{source_text: $src,
    input_kind: "code",
    emphasis: "general",
    context: "Internal users service behind a gateway; no spec exists yet.",
    prescan_facts: {routes: [], 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'
SOURCE_TEXT = """const express = require("express");
const app = express();

app.get("/users/:id", async (req, res) => {
  res.json(await db.users.find(req.params.id));
});

app.post("/createUser", async (req, res) => {
  res.json(await db.users.insert(req.body));
});
"""

payload = {
    "source_text": SOURCE_TEXT,
    "input_kind": "code",
    "emphasis": "general",
    "context": "Internal users service behind a gateway; no spec exists yet.",
    "prescan_facts": {"routes": [], "flags": []},
}

est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const sourceText = [
  'const express = require("express");',
  "const app = express();",
  "",
  'app.get("/users/:id", async (req, res) => {',
  "  res.json(await db.users.find(req.params.id));",
  "});",
  "",
  'app.post("/createUser", async (req, res) => {',
  "  res.json(await db.users.insert(req.body));",
  "});"
].join("\n");

const payload = {
  source_text: sourceText,
  input_kind: "code",
  emphasis: "general",
  context: "Internal users service behind a gateway; no spec exists yet.",
  prescan_facts: { routes: [], flags: [] },
};

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
const sourceText = "const express = require(\"express\");\n" +
	"const app = express();\n" +
	"\n" +
	"app.get(\"/users/:id\", async (req, res) => {\n" +
	"  res.json(await db.users.find(req.params.id));\n" +
	"});\n" +
	"\n" +
	"app.post(\"/createUser\", async (req, res) => {\n" +
	"  res.json(await db.users.insert(req.body));\n" +
	"});\n"

payload := map[string]any{
	"source_text": sourceText,
	"input_kind":  "code",
	"emphasis":    "general",
	"context":     "Internal users service behind a gateway; no spec exists yet.",
	"prescan_facts": map[string]any{
		"routes": []any{}, "flags": []any{},
	},
}

var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", payload, &est)
String sourceText = """
    const express = require("express");
    const app = express();

    app.get("/users/:id", async (req, res) => {
      res.json(await db.users.find(req.params.id));
    });

    app.post("/createUser", async (req, res) => {
      res.json(await db.users.insert(req.body));
    });
    """;

String jsonPayload = """
    {"source_text": %s,
     "input_kind": "code",
     "emphasis": "general",
     "context": "Internal users service behind a gateway; no spec exists yet.",
     "prescan_facts": {"routes": [], "flags": []}}
    """.formatted(toJsonString(sourceText));

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
SOURCE_TEXT = <<~SOURCE
  const express = require("express");
  const app = express();

  app.get("/users/:id", async (req, res) => {
    res.json(await db.users.find(req.params.id));
  });

  app.post("/createUser", async (req, res) => {
    res.json(await db.users.insert(req.body));
  });
SOURCE

payload = { source_text: SOURCE_TEXT,
            input_kind: "code",
            emphasis: "general",
            context: "Internal users service behind a gateway; no spec exists yet.",
            prescan_facts: { routes: [], flags: [] } }

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$sourceText = <<<'SOURCE'
const express = require("express");
const app = express();

app.get("/users/:id", async (req, res) => {
  res.json(await db.users.find(req.params.id));
});

app.post("/createUser", async (req, res) => {
  res.json(await db.users.insert(req.body));
});
SOURCE;

$payload = [
    "source_text"   => $sourceText,
    "input_kind"    => "code",
    "emphasis"      => "general",
    "context"       => "Internal users service behind a gateway; no spec exists yet.",
    "prescan_facts" => ["routes" => [], "flags" => []],
];

$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var sourceText = """
    const express = require("express");
    const app = express();

    app.get("/users/:id", async (req, res) => {
      res.json(await db.users.find(req.params.id));
    });

    app.post("/createUser", async (req, res) => {
      res.json(await db.users.insert(req.body));
    });
    """;

var payload = new {
    source_text = sourceText,
    input_kind = "code",
    emphasis = "general",
    context = "Internal users service behind a gateway; no spec exists yet.",
    prescan_facts = new {
        routes = 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 review answer for things you already know about. Send {"routes": [{"id": "file:routes-js", "label": "routes.js - 11 lines, 2 routes detected"}], "flags": [{"id": "verb-in-path:routes-js:post-createuser", "label": "POST /createUser bakes the verb into the path"}]} 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 CI check.

Step 4 — Run the generator 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 40–120 s, since the reply carries a complete OpenAPI 3.1 document as well as the findings). Always send an Idempotency-Key header so a network retry can't start a second, double-charged run. The reply is in output — usually nested as output.output, and as a JSON string, so parse defensively. The samples below print the posture, the operation inventory, the prioritized findings and the focus areas, then save the whole object to review.json and the spec on its own to openapi.json.

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: sc-$(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 reply once, then read it
echo "$JOB" | jq -r '.data.output.output' > review.json

# the spec is the primary product — pull it out on its own
jq '.spec' review.json > openapi.json

jq -r '
  "\(.review_name) [\(.posture)]: \(.verdict)",
  "",
  "INVENTORY",
  (.inventory[] | "  \(.method) \(.path) \(.operation_id) - \(.role)"),
  "",
  "FINDINGS",
  (.findings[] | "  [\(.priority)] \(.id) \(.category) \(.location): \(.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": "sc-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["method"]:<7} {r["path"]:<28} {r["operation_id"]:<22} {r["role"]}')
for f in review["findings"]:
    print(f'  [{f["priority"]:>8}] {f["id"]} {f["category"]} {f["location"]}')
    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)
with open("openapi.json", "w", encoding="utf-8") as fh:
    json.dump(review["spec"], 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.method} ${r.path} (${r.operation_id}): ${r.role}`);
}
for (const f of review.findings) {
  console.log(`  [${f.priority}] ${f.id} ${f.category} ${f.location}`);
  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));
writeFileSync("openapi.json", JSON.stringify(review.spec, 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"`
	Spec json.RawMessage `json:"spec"` // the complete OpenAPI 3.1 document
	Inventory []struct {
		Method, Path, Role string
		OperationID        string `json:"operation_id"`
	} `json:"inventory"`
	Findings []struct {
		ID, Category, Severity, Likelihood, Priority string
		Location, 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.Method, r.Path, r.OperationID, r.Role)
}
for _, f := range review.Findings {
	fmt.Printf("  [%s] %s %s %s: %s\n", f.Priority, f.ID, f.Category, f.Location, 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)
os.WriteFile("openapi.json", review.Spec, 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 reply is at data.output.output as a JSON string — parse it again, then read
// review_name, posture, verdict, exec_summary, assumptions[], open_questions[],
// spec (the complete OpenAPI 3.1 document object — the primary product),
// inventory[] (method/path/operation_id/role),
// findings[] (id/category/severity/likelihood/priority/location/problem/impact/fix/snippet),
// coverage_check[] (id/addressed/note), quick_wins[],
// focus_areas[] (area/why/finding_ids[]) and summary.
// Finally keep both on disk:
//   Files.writeString(Path.of("review.json"), reviewJson);
//   Files.writeString(Path.of("openapi.json"), specJson);
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["method"]} #{r["path"]} (#{r["operation_id"]}): #{r["role"]}" }
review["findings"].each do |f|
  puts "  [#{f["priority"]}] #{f["id"]} #{f["category"]} #{f["location"]}"
  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))
File.write("openapi.json", JSON.pretty_generate(review["spec"]))
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['method']} {$r['path']} ({$r['operation_id']}): {$r['role']}\n";
}
foreach ($review["findings"] as $f) {
    echo "  [{$f['priority']}] {$f['id']} {$f['category']} {$f['location']}\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));
file_put_contents("openapi.json", json_encode($review["spec"], 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("method")} {r.GetProperty("path")} " +
                      $"({r.GetProperty("operation_id")}): {r.GetProperty("role")}");
}
foreach (var f in review.GetProperty("findings").EnumerateArray())
{
    Console.WriteLine($"  [{f.GetProperty("priority")}] {f.GetProperty("id")} " +
                      $"{f.GetProperty("category")} {f.GetProperty("location")} " +
                      $"(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!);
await File.WriteAllTextAsync("openapi.json",
    review.GetProperty("spec").GetRawText());

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 reply object — output schema

One JSON object, always the same shape. Every array is present, and both the spec and the review are grounded in the pasted text alone: the specification documents only endpoints that actually appear in source_text (or are explicitly described, for notes input), and findings cite operations and sections that exist in it. Where the paste is silent on something that materially changes the contract — the auth model, the base URL, the pagination convention, the error shape — a sensible default is chosen and recorded in assumptions and, if it would change the ranking, in open_questions. Expect five to twelve findings on a typical paste — a clean, complete existing spec may honestly yield two or three, and findings is never empty.

FieldTypeMeaning
review_namestringA short title naming the API, taken from the paste's own naming — e.g. Orders API — spec and contract review.
posturestringship-ready | polish-recommended | not-ship-ready. See the table below.
verdictstringOne sentence justifying the posture and naming the single most important change.
exec_summarystringTwo or three paragraphs, separated by blank lines, on the dominant themes across the contract.
assumptionsstring[]Explicit assumptions filling gaps the paste left open — an inferred field type, a placeholder server URL. Read these first: a wrong assumption invalidates the spec and the findings built on it.
open_questionsstring[]Questions whose answers would change the contract or the ranking.
specobjectThe primary product — a complete, valid OpenAPI 3.1 document as a JSON object: openapi, a real info, at least one entry under servers, every discovered endpoint under paths with an operationId, request and response schemas under components/schemas referenced by $ref, documented error responses, and components/securitySchemes whenever an operation needs auth. It is never abbreviated with "..." placeholders, so write it straight to openapi.json and feed it to your generator or linter.
inventoryarray{method, path, operation_id, role} — one entry for every operation in spec.paths, exactly once, with a one-line description of what it does.
findingsarrayThe prioritized findings table — ids AP-001, AP-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[]One-line changes worth doing immediately, ahead of any planning. May be empty when nothing here is a one-liner.
focus_areasarray{area, why, finding_ids} — what to work through first, one sentence tied to the review, and the finding ids that motivate it. Every id in finding_ids exists in findings.
summarystringClosing paragraph: what to fix first, and what remains after that.

The three posture values:

postureWhat it means
ship-readyThe contract holds up as written: every operation documented with a summary, schemas and realistic examples, error responses declared, auth described, consistent resource naming and parameter style. Findings still exist, but they are what-to-add-next items, not blockers. A genuinely clean spec lands here rather than having severity manufactured for it.
polish-recommendedThe shape is right, but named gaps should be closed before consumers integrate — missing operationIds, undocumented 4xx responses, inline schemas that should be shared $refs, mixed casing or parameter styles, pagination that is implied rather than declared.
not-ship-readyAt least one thing breaks consumers or tooling outright as written: a mutating operation with no security scheme, unresolvable $refs, responses with no schema at all, duplicate operation ids, or a contract that contradicts what the handlers actually return.

Each entry in findings:

ColumnMeaning
idSequential AP-001, AP-002, … — the stable handle referenced from focus_areas[].finding_ids.
categorycompleteness | correctness | consistency | security | docs-quality | versioning | hygiene. Weighted by the emphasis 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 something that breaks consumers or tooling outright or leaves a mutating endpoint unsecured, so sort on this field and work top-down. This is also the field to gate a pipeline on.
locationThe operation or section this is about — e.g. POST /users or components.securitySchemes.
problemWhat is wrong, in this API specifically.
impactWhat happens to real consumers or tooling because of it.
fixThe concrete change to make — not "improve the docs".
snippetA corrected OpenAPI fragment in YAML you can paste: the fixed block, correctly indented, not the whole document. Empty string when a snippet would add nothing. Credentials found in the paste are never echoed — a placeholder appears instead.

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 CI check. Ids in prescan_facts.routes are not reconciled here — they shape the spec and the inventory instead.
addressed: trueThe flag is covered by the review or already fixed in the emitted spec; 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 API (an unversioned internal service with a single consumer, a verb-style path kept as a deprecated alias, called out in context).
Nothing sentOmit prescan_facts, or send the two empty arrays, and coverage_check comes back empty. The rest of the reply is unaffected.

A small, realistic result for the two routes above (long strings wrapped for readability):

{
  "review_name": "Users API — spec and contract review",
  "posture": "not-ship-ready",
  "verdict": "The contract is now documented, but nothing gates the write path and /createUser
              bakes a verb into the URL; secure the mutation before consumers integrate.",
  "exec_summary": "Two Express routes: a lookup by id and a creation endpoint on a verb-style
                   path. The spec below documents both, gives them shared schemas and a
                   normalized error response.

                   The dominant themes are security and consistency — no scheme guards the
                   POST, and the two paths already disagree about how a resource is named.",
  "assumptions": [
    "Handlers return JSON; the user shape is inferred from the res.json() calls.",
    "Base URL unknown; https://api.example.com/v1 is used as a placeholder."
  ],
  "open_questions": [
    "Does the gateway in front of this service already authenticate callers?",
    "Should /users support listing and pagination, or is lookup-by-id the whole surface?"
  ],
  "spec": {
    "openapi": "3.1.0",
    "info": { "title": "Users API", "version": "1.0.0" },
    "servers": [{ "url": "https://api.example.com/v1" }],
    "paths": {
      "/users/{id}": {
        "get": {
          "operationId": "getUser", "summary": "Fetch a user by id",
          "parameters": [{ "name": "id", "in": "path", "required": true,
                           "schema": { "type": "string" } }],
          "responses": {
            "200": { "description": "The user", "content": { "application/json": {
                       "schema": { "$ref": "#/components/schemas/User" } } } },
            "404": { "$ref": "#/components/responses/NotFound" } } } },
      "/users": {
        "post": {
          "operationId": "createUser", "summary": "Create a user",
          "requestBody": { "required": true, "content": { "application/json": {
                             "schema": { "$ref": "#/components/schemas/NewUser" } } } },
          "responses": {
            "201": { "description": "Created",
                     "headers": { "Location": { "schema": { "type": "string" } } },
                     "content": { "application/json": {
                       "schema": { "$ref": "#/components/schemas/User" } } } },
            "400": { "$ref": "#/components/responses/BadRequest" } } } }
    },
    "components": {
      "schemas": {
        "User": { "type": "object", "required": ["id", "email"], "properties": {
                    "id": { "type": "string" }, "email": { "type": "string", "format": "email" },
                    "name": { "type": ["string", "null"] } } },
        "NewUser": { "type": "object", "required": ["email"], "properties": {
                       "email": { "type": "string", "format": "email" },
                       "name": { "type": "string" } } },
        "Error": { "type": "object", "properties": {
                     "code": { "type": "string" }, "message": { "type": "string" } } }
      },
      "responses": {
        "NotFound": { "description": "No such user", "content": { "application/json": {
                        "schema": { "$ref": "#/components/schemas/Error" } } } },
        "BadRequest": { "description": "Invalid request", "content": { "application/json": {
                          "schema": { "$ref": "#/components/schemas/Error" } } } }
      },
      "securitySchemes": {
        "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" }
      }
    },
    "security": [{ "bearerAuth": [] }]
  },
  "inventory": [
    { "method": "GET", "path": "/users/{id}", "operation_id": "getUser",
      "role": "Returns a single user by its identifier." },
    { "method": "POST", "path": "/users", "operation_id": "createUser",
      "role": "Creates a user from an email and optional name." }
  ],
  "findings": [
    { "id": "AP-001", "category": "security",
      "severity": "high", "likelihood": "high", "priority": "critical",
      "location": "POST /users",
      "problem": "No authentication anywhere in the code; the creation route mutates state
                  for any caller that can reach it.",
      "impact": "Anyone on the network can write users, and every generated client will omit
                 auth too, baking the gap into each consumer.",
      "fix": "Adopt the bearerAuth scheme the spec declares and enforce it in middleware.",
      "snippet": "security:\n  - bearerAuth: []" },
    { "id": "AP-002", "category": "consistency",
      "severity": "medium", "likelihood": "high", "priority": "high",
      "location": "POST /createUser",
      "problem": "The verb-style path duplicates the meaning of the HTTP method and disagrees
                  with the /users/{id} resource already served next to it.",
      "impact": "SDK generators derive awkward names, and the URL style will keep drifting as
                 endpoints are added.",
      "fix": "Serve POST /users (the spec documents this) and keep /createUser as a deprecated
              alias until clients migrate.",
      "snippet": "" },
    { "id": "AP-003", "category": "completeness",
      "severity": "medium", "likelihood": "medium", "priority": "medium",
      "location": "GET /users/{id}",
      "problem": "The handler documents no failure case; a missing user has no declared shape.",
      "impact": "Consumers guess at the 404 body and error handling diverges per client.",
      "fix": "Declare the shared Error schema on 404, as the spec now does.",
      "snippet": "'404':\n  $ref: '#/components/responses/NotFound'" }
  ],
  "coverage_check": [
    { "id": "verb-in-path:routes-js:post-createuser", "addressed": true, "note": "AP-002." },
    { "id": "no-version:routes-js", "addressed": false,
      "note": "Only two routes and the context says a gateway prefixes /v1 already." }
  ],
  "quick_wins": [
    "Return 201 with a Location header from the create handler instead of 200.",
    "Give both operations an explicit operationId matching the spec above."
  ],
  "focus_areas": [
    { "area": "Authentication on writes",
      "why": "Nothing else matters while the creation endpoint is open to the network.",
      "finding_ids": ["AP-001"] },
    { "area": "Resource naming and error contract",
      "why": "The URL style and the failure shapes must settle before clients are generated.",
      "finding_ids": ["AP-002", "AP-003"] }
  ],
  "summary": "Enforce bearer auth on the mutation, move creation to POST /users, declare the
              error responses, then regenerate clients from the spec. …"
}

This is an AI-generated spec and review from pasted text, not a signed-off contract: it sees only the text you sent, never the running service, its gateway or its database. Check assumptions and open_questions before you act on the rankings, validate the emitted document with a real linter (spectral lint openapi.json) and against live responses, and keep a human reviewer in the loop.

Step 5 — Stream the spec 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 complete OpenAPI document plus a findings table 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", "spec", "inventory", "findings", "coverage_check" and "focus_areas" keys as they arrive.
done{job_id, status, charged_credits, output}The final, authoritative result — read the spec and review 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: sc-$(date +%s)" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"{\"review_name\":\"Users API"}
# ...
# 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": "sc-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["location"]}: {f["problem"]}')
with open("review.json", "w", encoding="utf-8") as fh:
    json.dump(review, fh, indent=2)
with open("openapi.json", "w", encoding="utf-8") as fh:
    json.dump(review["spec"], 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.location}`);
writeFileSync("review.json", JSON.stringify(review, null, 2));
writeFileSync("openapi.json", JSON.stringify(review.spec, 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", "sc-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 reply JSON —
// unmarshal it into the Review struct from step 4, then write it to review.json
// and review.Spec to openapi.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", "sc-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, spec (the OpenAPI 3.1 document), 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"] = "sc-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["location"]}" }
File.write("review.json", JSON.pretty_generate(review))
File.write("openapi.json", JSON.pretty_generate(review["spec"]))
$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: sc-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['location']}\n";
}
file_put_contents("review.json", json_encode($review, JSON_PRETTY_PRINT));
file_put_contents("openapi.json", json_encode($review["spec"], JSON_PRETTY_PRINT));
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "sc-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("location")}");
await File.WriteAllTextAsync("review.json", text!);
await File.WriteAllTextAsync("openapi.json", review.GetProperty("spec").GetRawText());

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.