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.
| Status | Meaning |
|---|---|
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/credits. |
403 | The token isn't allowed to do this (e.g. a guest submitting a very large paste). |
404 | Unknown job or record id. |
5xx | Transient platform error — retry with backoff. |
Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.
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
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
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
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 field | Type | Notes |
|---|---|---|
source_text | string, required | The 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_kind | string | code | 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. |
emphasis | string | general | 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. |
context | string, optional | Extra 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_facts | object, optional | What 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_note | string, optional | Only 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
/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.
| Field | Type | Meaning |
|---|---|---|
review_name | string | A short title naming the API, taken from the paste's own naming — e.g. Orders API — spec and contract review. |
posture | string | ship-ready | polish-recommended | not-ship-ready. See the table below. |
verdict | string | One sentence justifying the posture and naming the single most important change. |
exec_summary | string | Two or three paragraphs, separated by blank lines, on the dominant themes across the contract. |
assumptions | string[] | 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_questions | string[] | Questions whose answers would change the contract or the ranking. |
spec | object | The 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. |
inventory | array | {method, path, operation_id, role} — one entry for every operation in spec.paths, exactly once, with a one-line description of what it does. |
findings | array | The prioritized findings table — ids AP-001, AP-002, … in sequence, at least one entry. Columns are listed below. |
coverage_check | array | {id, addressed, note} — one entry per prescan_facts.flags id you sent, each appearing exactly once. See the semantics below. |
quick_wins | string[] | One-line changes worth doing immediately, ahead of any planning. May be empty when nothing here is a one-liner. |
focus_areas | array | {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. |
summary | string | Closing paragraph: what to fix first, and what remains after that. |
The three posture values:
| posture | What it means |
|---|---|
ship-ready | The 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-recommended | The 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-ready | At 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:
| Column | Meaning |
|---|---|
id | Sequential AP-001, AP-002, … — the stable handle referenced from focus_areas[].finding_ids. |
category | completeness | correctness | consistency | security | docs-quality | versioning | hygiene. Weighted by the emphasis you sent, but never restricted to it. |
severity | low | medium | high — how bad it is when it bites. |
likelihood | low | medium | high — how likely it is to bite. |
priority | critical | 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. |
location | The operation or section this is about — e.g. POST /users or components.securitySchemes. |
problem | What is wrong, in this API specifically. |
impact | What happens to real consumers or tooling because of it. |
fix | The concrete change to make — not "improve the docs". |
snippet | A 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:
| Case | What you get |
|---|---|
| Every flag id you sent | Each 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: true | The flag is covered by the review or already fixed in the emitted spec; note names the finding id that covers it. |
addressed: false | The 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 sent | Omit 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
/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.
| Event | Payload | Meaning |
|---|---|---|
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.