MCP App Clinic — API

Paste the tool, the ui:// resource and the iframe UI, get a structured host-readiness review.

API tokens Open the app

Review your MCP App code from your own scripts

Send the source — the server file that registers the tool and the ui:// resource, the app-side code that runs in the host's sandboxed iframe, the build config and the stylesheet, one file or several, each preceded by a // file: server.ts comment — and get back one JSON object: a host-readiness posture, the inventory of every tool, resource, lifecycle handler, transport, declared CSP domain, app-to-server call and build step with its role, prioritized findings across lifecycle, CSP, server wiring, host integration, migration, build and hygiene, each with a corrected TypeScript fragment, a before-and-after migration map off the OpenAI Apps SDK, a reconciliation of every prescan flag you send, 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 hang a review off any pull request that touches the widget. Failures in this runtime are silent: a missing CSP origin, a snake_case key, a handler assigned after connect() all fail with no error anywhere, which is exactly why a pre-merge check earns its keep. Pick a language once and the whole page follows.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api, app slug mcp-app-clinic. Every request sends Authorization: Bearer <token> and JSON bodies with Content-Type: application/json. Responses are wrapped in an envelope: {"data": …} on success, {"error": {"code", "message"}} on failure. The review itself is produced by the gpt-terra model. Estimates are free; runs are metered against your credit balance and need a personal (signed-in) token, so mint the token once and keep it in your CI secret store. There is a single run task — one bundle of MCP App code in, one review out, no follow-up calls and no session state to carry.

StatusMeaning
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/credits.
403The token isn't allowed to do this — most often a guest token trying to start a metered run.
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. Review runs are metered and billed to your own account, so they need 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 — enough for /me and /estimate, and it gets a 403 from /run.

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

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

Step 2 — Check who you are and your balance

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance. Check this before reviewing a large bundle — and in CI, check it first so a run never dies halfway with a 402.

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 web/src/ directory plus server.ts in and want a ceiling before spending credits.

Input fieldTypeNotes
codestring, requiredThe MCP App source: the server file registering the tool and the ui:// resource, the app-side code that runs in the host's iframe, the Vite config, the widget stylesheet. One file or several concatenated, each preceded by a // file: server.ts comment. This is the model's only evidence — nothing is executed and no server is contacted. Inputs longer than 100,000 characters are clipped middle-out, with a [... clipped N characters from the middle ...] marker showing where. At least 60 characters are needed for a review.
modestringreview | openai-migration | web-conversion | new-app — what you are doing. review audits an MCP App you already have; openai-migration puts the migration map at the centre of the review; web-conversion adds MCP App support to an existing web app and keeps the standalone build working, so changes that would break it are flagged; new-app weights missing pieces over existing ones. It tells the review what you are trying to do; it never suppresses a finding from another direction.
concernstringgeneral | lifecycle | csp | server-wiring | host-integration — 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: the target hosts, the SDK version, whether the same code must also run standalone in a browser, how the server is deployed (stdio or streamable HTTP), what the host actually shows when it goes wrong, and any gap you have already chosen to accept. Clipped at 20,000 characters.
prescan_factsobject, optionalWhat a client-side scanner mechanically matched in the code: {"resources": [], "flags": []}. Each entry is {id, label}. Resource ids look like res:tool/show-board, res:resource/ui-widget-board-html, res:handler/ontoolresult, res:transport/postmessagetransport, res:csp/api-example-com or res:build/vite; flag ids are <check>:<name>openai-global:tooloutput-75, openai-meta:outputtemplate, snake-domains:connect-domains, handler-after-connect:ontoolresult, raw-register:tool-show-board, tool-resource-link:no-uri-show-board, no-text-fallback:show-board, undeclared-origin:api-example-com, sandbox-storage:localstorage-88, no-singlefile:vite, no-safe-area:insets, hardcoded-theme:colors. 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 > app.ts <<'CODE'
// file: server.ts
server.registerTool("show_board", {
  _meta: { "openai/outputTemplate": "ui://widget/board.html" },
}, showBoard);

// file: web/src/widget.ts
const board = window.openai.toolOutput;
render(board);
CODE

jq -n --rawfile code app.ts \
  '{code: $code,
    mode: "openai-migration",
    concern: "general",
    context: "Porting a tldraw board widget off the OpenAI Apps SDK; target host is Claude Desktop.",
    prescan_facts: {resources: [], 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'
CODE = """// file: server.ts
server.registerTool("show_board", {
  _meta: { "openai/outputTemplate": "ui://widget/board.html" },
}, showBoard);

// file: web/src/widget.ts
const board = window.openai.toolOutput;
render(board);
"""

payload = {
    "code": CODE,
    "mode": "openai-migration",
    "concern": "general",
    "context": "Porting a tldraw board widget off the OpenAI Apps SDK; target host is Claude Desktop.",
    "prescan_facts": {"resources": [], "flags": []},
}

est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const code = [
  '// file: server.ts',
  'server.registerTool("show_board", {',
  '  _meta: { "openai/outputTemplate": "ui://widget/board.html" },',
  '}, showBoard);',
  '',
  '// file: web/src/widget.ts',
  'const board = window.openai.toolOutput;',
  'render(board);',
].join("\n");

const payload = {
  code,
  mode: "openai-migration",
  concern: "general",
  context: "Porting a tldraw board widget off the OpenAI Apps SDK; target host is Claude Desktop.",
  prescan_facts: { resources: [], flags: [] },
};

const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
const code = `// file: server.ts
server.registerTool("show_board", {
  _meta: { "openai/outputTemplate": "ui://widget/board.html" },
}, showBoard);

// file: web/src/widget.ts
const board = window.openai.toolOutput;
render(board);`

payload := map[string]any{
	"code":    code,
	"mode":    "openai-migration",
	"concern": "general",
	"context": "Porting a tldraw board widget off the OpenAI Apps SDK; target host is Claude Desktop.",
	"prescan_facts": map[string]any{
		"resources": []any{}, "flags": []any{},
	},
}

var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", payload, &est)
String code = """
    // file: server.ts
    server.registerTool("show_board", {
      _meta: { "openai/outputTemplate": "ui://widget/board.html" },
    }, showBoard);

    // file: web/src/widget.ts
    const board = window.openai.toolOutput;
    render(board);
    """;

String jsonPayload = """
    {"code": %s,
     "mode": "openai-migration",
     "concern": "general",
     "context": "Porting a tldraw board widget off the OpenAI Apps SDK; target host is Claude Desktop.",
     "prescan_facts": {"resources": [], "flags": []}}
    """.formatted(toJsonString(code));

String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
CODE = <<~TS
  // file: server.ts
  server.registerTool("show_board", {
    _meta: { "openai/outputTemplate": "ui://widget/board.html" },
  }, showBoard);

  // file: web/src/widget.ts
  const board = window.openai.toolOutput;
  render(board);
TS

payload = { code: CODE,
            mode: "openai-migration",
            concern: "general",
            context: "Porting a tldraw board widget off the OpenAI Apps SDK; target host is Claude Desktop.",
            prescan_facts: { resources: [], flags: [] } }

est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$code = <<<'TS'
// file: server.ts
server.registerTool("show_board", {
  _meta: { "openai/outputTemplate": "ui://widget/board.html" },
}, showBoard);

// file: web/src/widget.ts
const board = window.openai.toolOutput;
render(board);
TS;

$payload = [
    "code"          => $code,
    "mode"          => "openai-migration",
    "concern"       => "general",
    "context"       => "Porting a tldraw board widget off the OpenAI Apps SDK; target host is Claude Desktop.",
    "prescan_facts" => ["resources" => [], "flags" => []],
];

$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var code = """
    // file: server.ts
    server.registerTool("show_board", {
      _meta: { "openai/outputTemplate": "ui://widget/board.html" },
    }, showBoard);

    // file: web/src/widget.ts
    const board = window.openai.toolOutput;
    render(board);
    """;

var payload = new {
    code,
    mode = "openai-migration",
    concern = "general",
    context = "Porting a tldraw board widget off the OpenAI Apps SDK; target host is Claude Desktop.",
    prescan_facts = new {
        resources = 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 {"resources": [{"id": "res:tool/show-board", "label": "Tool/show_board"}], "flags": [{"id": "openai-global:tooloutput-7", "label": "window.openai.toolOutput in widget.ts"}]} 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 review and wait for the result

POST /run
GET /jobs/{job_id}

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

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

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

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

jq -r '
  "\(.review_name) [\(.posture)]: \(.verdict)",
  "",
  "INVENTORY",
  (.inventory[] | "  \(.kind)/\(.name) in \(.scope) - \(.role)"),
  "",
  "FINDINGS",
  (.findings[] | "  [\(.priority)] \(.id) \(.category) \(.resource): \(.problem)"),
  "",
  "MIGRATION MAP",
  (.migration_map[] | "  \(.pattern) -> \(.replacement)"),
  "",
  "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": "mac-001"})["job_id"]

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

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

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

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

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

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

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

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

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

const raw = job.output?.output ?? job.output;
const review = typeof raw === "string" ? JSON.parse(raw) : raw;

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

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

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

var job struct {
	Status string          `json:"status"`
	Error  string          `json:"error"`
	Output json.RawMessage `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
		log.Fatal(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(1500 * time.Millisecond)
}

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

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

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

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

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

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

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

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

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

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

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

file_put_contents("review.json", json_encode($review, JSON_PRETTY_PRINT));
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload);
var jobId = started.GetProperty("job_id").GetString();

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

var rawText = job.GetProperty("output").GetProperty("output").GetString();
using var doc = JsonDocument.Parse(rawText!);
var review = doc.RootElement;

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

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

The Idempotency-Key is yours to choose, but derive it from the input rather than from the clock and a retry replays instead of re-billing. The app itself sends mcp-app-clinic:<hash>:a<attempt>, where hash is a 32-bit FNV-1a over code, mode, concern and context joined with spaces, printed as eight hex digits plus : and the joined length — so mcp-app-clinic:3f2a91c7:842:a1. The attempt counter is what lets its one automatic reformat retry (the retry_note run) be a genuinely new job instead of a replay of the first. Step 6 builds the same kind of key for CI.

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

One JSON object, always the same shape. Every array is present, and the review is grounded in the pasted source alone: findings cite only tools, resources, handlers, origins, files and options that actually appear in code or in prescan_facts, and half an app — a server with no UI, a UI with no server — is reviewed as what it is, with the gap recorded in assumptions. Where the code is silent on something that changes the verdict you get an entry in assumptions and, if it would change the ranking, in open_questions. Expect five to fifteen findings on a typical app — a well-built one may honestly yield two or three, and findings is never empty.

FieldTypeMeaning
review_namestringA short title naming the app, taken from the code's own naming — e.g. pizza map — half-migrated OpenAI widget.
posturestringhost-ready | hardening-recommended | host-breaking. See the table below.
verdictstringOne sentence justifying the posture and naming the single most important change.
exec_summarystringTwo to four paragraphs, separated by blank lines, on the dominant themes across the app.
assumptionsstring[]Explicit assumptions filling gaps the paste left open. Read these first — a wrong assumption invalidates the findings built on it.
open_questionsstring[]Questions whose answers would change the ranking.
inventoryarray{kind, name, scope, role} — the app's surface and the part each piece plays. kind is one of Tool, Resource, Handler, Transport, CSP, HostCall, Build, Server, Origin; scope is the file it is defined in. Everything you sent under prescan_facts.resources appears here, plus anything else the review finds.
findingsarrayThe prioritized findings table — ids MA-001, MA-002, … in sequence, at least one entry. Columns are listed below.
migration_maparray{pattern, note, replacement} — one row per OpenAI Apps SDK pattern that actually appears in the paste. See the semantics below. [] when there is none; rows are never invented to fill the table.
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} — two to four areas 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 you can paste into a ticket: what to fix first, and what risk remains after that.

The three posture values:

postureWhat it means
host-readyNo defect that would break or embarrass the app in a host: all handlers are registered before connect(), the tool and the ui:// resource point at each other, every origin the code touches is declared, the build inlines to a single file, no browser storage in the sandbox. Findings still exist, but they are additions and refinements — ontoolinputpartial progress, updateModelContext, safe-area padding — at low or medium priority. Genuinely well-built apps land here rather than having severity manufactured for them.
hardening-recommendedIt runs inside a host, but there are real defects to fix before users see it — a result with no text fallback, hardcoded colors that ignore the host theme, an unheeded onteardown, a dev origin declared but never switched with the runtime URL.
host-breakingSomething in the paste means the app does not work inside a host at all, or works only by accident: window.openai reads that are undefined there, a tool with no resourceUri, a ui:// URI no resource serves, a handler assigned after connect(), an origin missing from the CSP declaration, localStorage in the sandbox, a build with no single-file bundling.

Each entry in findings:

ColumnMeaning
idSequential MA-001, MA-002, … — the stable handle referenced from focus_areas[].finding_ids and from coverage_check[].note.
categorylifecycle | csp | server-wiring | host-integration | migration | build | hygiene. Weighted by the concern you sent, but never restricted to it.
severitylow | medium | high — how bad it is when it bites.
likelihoodlow | medium | high — how likely it is to bite.
prioritycritical | high | medium | low — severity by likelihood. critical is reserved for something that stops the app working in a host (a handler that misses its only event, an undeclared origin, an unserved ui://), so sort on this field and work top-down. This is also the field to gate a pipeline on — step 6 does exactly that.
resourceThe file and the construct this is about — web/src/widget.ts — app.ontoolresult, server.ts — registerAppTool("show_board") — always something that appears in the paste.
problemWhat is wrong, quoting the construct from this code specifically.
impactWhat the host actually does because of it: the card is blank on first render, the logo never loads, the theme ignores the host, the second call shows stale data.
fixThe concrete change to make — not "handle this properly".
snippetA corrected TypeScript fragment you can paste: the fixed block for their code, correctly indented, importing from the right package — not the whole file and not a generic template. Empty string when a prose fix is genuinely clearer. Secret values are never echoed — a placeholder appears instead.

migration_map semantics:

CaseWhat you get
A pattern with an MCP Apps equivalentpattern is the literal token from the code (window.openai.toolOutput, _meta["openai/outputTemplate"], text/html+skybridge, connect_domains), note is what it meant under the OpenAI Apps SDK, and replacement is the MCP Apps line to write instead.
A pattern with no equivalent yetreplacement says so plainly and gives the workaround — widgetState/setWidgetState becomes server-side state or tool arguments; uploadFile, requestModal and the invoking/invoked progress strings have no MCP counterpart today. Nothing is invented to fill the gap.
No OpenAI pattern in the paste[]. This is the normal result for mode: "review" and mode: "new-app", and an empty map on mode: "openai-migration" is itself worth reading: the port may already be done.

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.resources are not reconciled here — they shape the inventory instead.
addressed: trueThe flag is covered by the review; 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 app (an origin that only appears in a documentation URL, a localStorage call inside an isMcpApp ? … : … standalone-mode branch).
Nothing sentOmit prescan_facts, or send the two empty arrays, and coverage_check comes back empty. The rest of the review is unaffected.

A small, realistic result for the snippet above, trimmed for length:

{
  "review_name": "board widget — half-migrated OpenAI app",
  "posture": "host-breaking",
  "verdict": "Nothing in this paste runs in an MCP host: the UI reads window.openai, which is
              undefined there, and the tool never names a ui:// resource the host can render.",
  "exec_summary": "Two files, both still written against the OpenAI Apps SDK. The server
                   registers show_board with _meta[\"openai/outputTemplate\"], which an MCP
                   host does not read, so no UI is ever associated with the tool.

                   The widget reads its data synchronously from window.openai.toolOutput at
                   module scope. In an MCP App the data arrives asynchronously in ontoolresult,
                   so this is both an undefined-property crash and a paradigm change: the render
                   has to move into the handler, and the handler has to be registered before
                   connect().",
  "assumptions": [
    "The ui://widget/board.html URI is intended to be served by this same server; no resource
     registration for it appears in the paste.",
    "render() paints into the document the iframe already has; no host-context handling was sent."
  ],
  "open_questions": [
    "Does the board need to call back into the server after the first render, or is the
     structuredContent from show_board the whole story?",
    "Is there a standalone web build of this widget that must keep working?"
  ],
  "inventory": [
    { "kind": "Tool", "name": "show_board", "scope": "server.ts",
      "role": "The model-callable tool; registered raw, with OpenAI metadata." },
    { "kind": "Resource", "name": "ui://widget/board.html", "scope": "(missing from the paste)",
      "role": "Named by the tool's outputTemplate, but no registration was sent." },
    { "kind": "HostCall", "name": "window.openai.toolOutput", "scope": "web/src/widget.ts",
      "role": "How the UI currently gets its data; undefined in an MCP host." }
  ],
  "findings": [
    { "id": "MA-001", "category": "migration",
      "severity": "high", "likelihood": "high", "priority": "critical",
      "resource": "web/src/widget.ts — module scope",
      "problem": "window.openai.toolOutput is read once, synchronously, at module scope.",
      "impact": "In the host's sandboxed iframe window.openai does not exist, so the module
                 throws on load and the card stays blank forever.",
      "fix": "Create an App, move the render into an ontoolresult handler reading
              result.structuredContent, and connect only after every handler is assigned.",
      "snippet": "const app = new App({ name: \"board\" });\napp.ontoolresult = (result) => {\n  render(result.structuredContent);\n};\napp.onhostcontextchanged = (ctx) => applyDocumentTheme(ctx.theme);\nawait app.connect(new PostMessageTransport());" },
    { "id": "MA-002", "category": "server-wiring",
      "severity": "high", "likelihood": "high", "priority": "critical",
      "resource": "server.ts — server.registerTool(\"show_board\")",
      "problem": "The tool is registered raw and carries _meta[\"openai/outputTemplate\"]
                 instead of _meta.ui.resourceUri, and no resource serves ui://widget/board.html.",
      "impact": "An MCP host sees a tool with no UI at all: the call succeeds and the result is
                 rendered as plain text, with no iframe.",
      "fix": "Register with registerAppTool and give it the resourceUri, then serve the bundled
              HTML with registerAppResource using RESOURCE_MIME_TYPE.",
      "snippet": "registerAppTool(server, \"show_board\", {\n  _meta: { ui: { resourceUri: \"ui://widget/board.html\" } },\n}, showBoard);\n\nregisterAppResource(server, {\n  uri: \"ui://widget/board.html\",\n  name: \"Board\",\n  mimeType: RESOURCE_MIME_TYPE,\n}, () => readBundledHtml());" }
  ],
  "migration_map": [
    { "pattern": "_meta[\"openai/outputTemplate\"]",
      "note": "Named the widget template the host should render for this tool.",
      "replacement": "_meta.ui.resourceUri on the tool, written for you by registerAppTool." },
    { "pattern": "window.openai.toolOutput",
      "note": "The tool's output, read synchronously at startup.",
      "replacement": "app.ontoolresult = (result) => render(result.structuredContent)" }
  ],
  "coverage_check": [
    { "id": "openai-meta:outputtemplate", "addressed": true, "note": "MA-002." },
    { "id": "openai-global:tooloutput-7", "addressed": true, "note": "MA-001." },
    { "id": "raw-register:tool-show-board", "addressed": true, "note": "MA-002." }
  ],
  "quick_wins": [
    "Swap server.registerTool for registerAppTool — the helper writes the _meta.ui shape correctly."
  ],
  "focus_areas": [
    { "area": "App lifecycle",
      "why": "The UI has no App, no handlers and no transport; everything else depends on that.",
      "finding_ids": ["MA-001"] },
    { "area": "Tool and resource pairing",
      "why": "Until the tool names a resource that exists, the host renders no UI at all.",
      "finding_ids": ["MA-002"] }
  ],
  "summary": "Restructure the widget around App + ontoolresult + connect(), and re-register the
              tool and its ui:// resource with the app helpers. Once the card renders, declare
              every origin it fetches from in connectDomains and add single-file bundling to the
              build — both fail silently and neither shows up in a local browser test."
}

This is AI-generated review from source text, not a host certification: it sees only what you sent, never a running host, the built bundle or the rest of the repository. Check assumptions and open_questions before you act on the rankings, run every snippet through your own type checker, and load the app in a real host before you call it done.

Step 5 — Stream the review as it is written

POST /run-stream

/run-stream takes exactly the same body as /run — including the Idempotency-Key header — but answers with server-sent events, so you can show progress instead of a spinner. That is worth having here because a full findings table with corrected TypeScript plus a migration map makes for a long reply. This app's own progress panel is this endpoint. Events are separated by a blank line; each has an event: line and a data: line carrying JSON.

EventPayloadMeaning
job{job_id, status}Sent once, when the job is accepted — show "starting".
delta{text}A chunk of the reply, in order. Append it; the accumulated length is your only progress signal (the total is not known in advance). The app advances its step list by watching for the "review_name", "inventory", "findings", "migration_map", "coverage_check" and "focus_areas" keys as they arrive.
done{job_id, status, charged_credits, output}The final, authoritative result — read the 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: mac-$(date +%s)" \
  -d @input.json

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

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

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

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

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

const review = JSON.parse(done.output.output);
console.log(`\n${done.charged_credits} credits - ${review.review_name} [${review.posture}]`);
for (const f of review.findings) console.log(`  [${f.priority}] ${f.id} ${f.resource}`);
writeFileSync("review.json", JSON.stringify(review, null, 2));
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "mac-001")

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

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

var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
    if (line.startsWith("event:")) {
        event = line.substring(6).trim();
    } else if (line.startsWith("data:")) {
        String data = line.substring(5).trim();
        if ("delta".equals(event)) System.out.print(".");   // live progress
        else if ("done".equals(event)) done = data;
        else if ("error".equals(event)) throw new RuntimeException(data);
    }
}
// parse `done`, then parse data.output.output again — it is a JSON string holding
// review_name, posture, verdict, inventory[], findings[], migration_map[],
// 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"] = "mac-001"
req.body = payload.to_json

event = nil
done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      chunk.each_line do |line|
        line = line.strip
        if line.start_with?("event:")
          event = line.delete_prefix("event:").strip
        elsif line.start_with?("data:")
          data = JSON.parse(line.delete_prefix("data:").strip)
          case event
          when "delta" then print "."           # live progress
          when "done"  then done = data
          when "error" then raise (data["message"] || "run failed")
          end
        end
      end
    end
  end
end

review = JSON.parse(done["output"]["output"])
puts "\n#{done["charged_credits"]} credits - #{review["review_name"]} [#{review["posture"]}]"
review["findings"].each { |f| puts "  [#{f["priority"]}] #{f["id"]} #{f["resource"]}" }
File.write("review.json", JSON.pretty_generate(review))
$event = null;
$done  = null;

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

$review = json_decode($done["output"]["output"], true);
echo "\n{$done['charged_credits']} credits - {$review['review_name']} [{$review['posture']}]\n";
foreach ($review["findings"] as $f) {
    echo "  [{$f['priority']}] {$f['id']} {$f['resource']}\n";
}
file_put_contents("review.json", json_encode($review, JSON_PRETTY_PRINT));
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "mac-001");

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

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

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

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

Step 6 — Gate the build on the posture

The point of an API review is that it can say no. Concatenate the files that make up the app — server.ts, web/src/, vite.config.ts — each under its // file: line, run the review, and fail the job when posture is host-breaking or any finding has priority: "critical". Those are exactly the failures a local browser test cannot catch, because in this runtime they are silent.

The important detail is the key. Derive the Idempotency-Key from the input itself, not from the clock or the build number, and a re-run of the same commit — a flaky-job retry, a re-run of the whole workflow, a merge queue running the same tree twice — replays the first review instead of buying a second one. Change one character of the code, the mode, the concern or the context and the key changes with it, so a real change always gets a real review. The app derives its key the same way (a 32-bit FNV-1a); the samples below use a sha256 prefix because every language and every CI image already has one.

# ci-review.sh — exits non-zero when the app is not host-ready enough to merge
set -euo pipefail

# 1. build the input from the tree
{ for f in server.ts web/src/*.ts vite.config.ts; do
    printf '// file: %s\n' "$f"; cat "$f"; printf '\n\n'
  done; } > app-bundle.ts

jq -n --rawfile code app-bundle.ts \
  '{code: $code, mode: "review", concern: "general",
    context: "CI gate on '"$GITHUB_REF_NAME"'; hosts: Claude Desktop.",
    prescan_facts: {resources: [], flags: []}}' > input.json

# 2. an input-derived key: same tree, same key, replay instead of re-bill
KEY="mcp-app-clinic:ci:$(jq -r '[.code,.mode,.concern,.context] | join(" ")' input.json \
  | shasum -a 256 | cut -c1-16)"

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

until [ "$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN" \
  | jq -r '.data.status')" != "running" ]; do sleep 2; done

curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN" \
  | jq -r '.data.output.output' > review.json

# 4. gate
POSTURE=$(jq -r '.posture' review.json)
CRIT=$(jq '[.findings[] | select(.priority == "critical")] | length' review.json)

jq -r '.findings[] | select(.priority == "critical" or .priority == "high")
       | "::error title=\(.id) \(.category)::\(.resource): \(.problem)"' review.json

echo "posture=$POSTURE critical=$CRIT"
if [ "$POSTURE" = "host-breaking" ] || [ "$CRIT" -gt 0 ]; then
  jq -r '.verdict' review.json
  exit 1
fi
import glob, hashlib, json, sys, time

def bundle(paths):
    out = []
    for p in paths:
        with open(p, encoding="utf-8") as fh:
            out.append(f"// file: {p}\n{fh.read()}")
    return "\n\n".join(out)

payload = {
    "code": bundle(["server.ts", *sorted(glob.glob("web/src/*.ts")), "vite.config.ts"]),
    "mode": "review",
    "concern": "general",
    "context": "CI gate on the default branch; target host is Claude Desktop.",
    "prescan_facts": {"resources": [], "flags": []},
}

# Same tree, same key: a retried job replays the first review instead of buying a second.
digest = hashlib.sha256(
    " ".join(payload[k] for k in ("code", "mode", "concern", "context")).encode()
).hexdigest()[:16]
key = f"mcp-app-clinic:ci:{digest}"

job_id = api("POST", "/run", payload, **{"Idempotency-Key": key})["job_id"]
while (job := api("GET", f"/jobs/{job_id}"))["status"] == "running":
    time.sleep(2)
if job["status"] == "failed":
    sys.exit(job.get("error", "run failed"))

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

blocking = [f for f in review["findings"] if f["priority"] == "critical"]
for f in review["findings"]:
    if f["priority"] in ("critical", "high"):
        print(f'{f["priority"]:>8} {f["id"]} {f["category"]} {f["resource"]}: {f["fix"]}')

print(f'\n{review["review_name"]}: {review["posture"]} — {review["verdict"]}')
if review["posture"] == "host-breaking" or blocking:
    sys.exit(f'blocked: posture={review["posture"]}, {len(blocking)} critical finding(s)')
print("ok — nothing host-breaking, nothing critical")
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync, globSync } from "node:fs";

const files = ["server.ts", ...globSync("web/src/*.ts").sort(), "vite.config.ts"];
const code = files
  .map((f) => `// file: ${f}\n${readFileSync(f, "utf8")}`)
  .join("\n\n");

const payload = {
  code,
  mode: "review",
  concern: "general",
  context: "CI gate on the default branch; target host is Claude Desktop.",
  prescan_facts: { resources: [], flags: [] },
};

// Same tree, same key: a retried job replays the first review instead of buying a second.
const digest = createHash("sha256")
  .update([payload.code, payload.mode, payload.concern, payload.context].join(" "))
  .digest("hex")
  .slice(0, 16);

const { job_id } = await api("POST", "/run", payload,
  { "Idempotency-Key": `mcp-app-clinic:ci:${digest}` });

let job;
do {
  await new Promise((r) => setTimeout(r, 2000));
  job = await api("GET", `/jobs/${job_id}`);
} while (job.status === "running");
if (job.status === "failed") throw new Error(job.error ?? "run failed");

const review = JSON.parse(job.output?.output ?? job.output);
writeFileSync("review.json", JSON.stringify(review, null, 2));

for (const f of review.findings) {
  if (f.priority === "critical" || f.priority === "high") {
    console.log(`::error title=${f.id} ${f.category}::${f.resource}: ${f.problem}`);
  }
}

const blocking = review.findings.filter((f) => f.priority === "critical");
console.log(`${review.review_name}: ${review.posture} - ${review.verdict}`);
if (review.posture === "host-breaking" || blocking.length) {
  console.error(`blocked: ${blocking.length} critical finding(s)`);
  process.exitCode = 1;
}
files := []string{"server.ts", "web/src/widget.ts", "vite.config.ts"}
var b strings.Builder
for _, f := range files {
	src, err := os.ReadFile(f)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Fprintf(&b, "// file: %s\n%s\n\n", f, src)
}

code := b.String()
mode, concern := "review", "general"
ctx := "CI gate on the default branch; target host is Claude Desktop."

payload := map[string]any{
	"code": code, "mode": mode, "concern": concern, "context": ctx,
	"prescan_facts": map[string]any{"resources": []any{}, "flags": []any{}},
}

// Same tree, same key: a retried job replays the first review instead of buying a second.
sum := sha256.Sum256([]byte(strings.Join([]string{code, mode, concern, ctx}, " ")))
key := "mcp-app-clinic:ci:" + hex.EncodeToString(sum[:])[:16]

body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
// ... send, read data.job_id, then poll /jobs/{id} as in step 4 ...

// review is the parsed Review struct from step 4:
blocking := 0
for _, f := range review.Findings {
	if f.Priority == "critical" {
		blocking++
	}
	if f.Priority == "critical" || f.Priority == "high" {
		fmt.Printf("::error title=%s %s::%s: %s\n", f.ID, f.Category, f.Resource, f.Problem)
	}
}
fmt.Printf("%s: %s - %s\n", review.ReviewName, review.Posture, review.Verdict)
if review.Posture == "host-breaking" || blocking > 0 {
	os.Exit(1)
}
// Java 17+ — build the bundle, derive the key, run, then gate.
var files = List.of("server.ts", "web/src/widget.ts", "vite.config.ts");
var sb = new StringBuilder();
for (String f : files) {
    sb.append("// file: ").append(f).append('\n')
      .append(Files.readString(Path.of(f))).append("\n\n");
}
String code = sb.toString();
String mode = "review", concern = "general";
String ctx = "CI gate on the default branch; target host is Claude Desktop.";

// Same tree, same key: a retried job replays the first review instead of buying a second.
var md = MessageDigest.getInstance("SHA-256");
byte[] sum = md.digest(String.join(" ", code, mode, concern, ctx)
    .getBytes(StandardCharsets.UTF_8));
String key = "mcp-app-clinic:ci:" + HexFormat.of().formatHex(sum).substring(0, 16);

var req = HttpRequest.newBuilder(URI.create(API + "/run"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", key)
    .POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
    .build();
// ... send, read data.job_id, poll /jobs/{id} as in step 4, parse data.output.output ...

// With the review parsed: block the build on the posture and on critical findings.
//   boolean blocked = "host-breaking".equals(posture)
//       || findings.stream().anyMatch(f -> "critical".equals(f.priority()));
//   if (blocked) { System.err.println(verdict); System.exit(1); }
require "digest"

files = ["server.ts", *Dir["web/src/*.ts"].sort, "vite.config.ts"]
code = files.map { |f| "// file: #{f}\n#{File.read(f)}" }.join("\n\n")

payload = { code: code,
            mode: "review",
            concern: "general",
            context: "CI gate on the default branch; target host is Claude Desktop.",
            prescan_facts: { resources: [], flags: [] } }

# Same tree, same key: a retried job replays the first review instead of buying a second.
digest = Digest::SHA256.hexdigest(
  [payload[:code], payload[:mode], payload[:concern], payload[:context]].join(" ")
)[0, 16]

started = api("POST", "/run", payload) # add the header in your helper:
                                       #   req["Idempotency-Key"] = "mcp-app-clinic:ci:#{digest}"

job = nil
loop do
  job = api("GET", "/jobs/#{started["job_id"]}")
  break unless job["status"] == "running"
  sleep 2
end
abort(job["error"] || "run failed") if job["status"] == "failed"

review = JSON.parse(job["output"]["output"])
File.write("review.json", JSON.pretty_generate(review))

review["findings"].each do |f|
  next unless %w[critical high].include?(f["priority"])
  puts "::error title=#{f["id"]} #{f["category"]}::#{f["resource"]}: #{f["problem"]}"
end

blocking = review["findings"].count { |f| f["priority"] == "critical" }
puts "#{review["review_name"]}: #{review["posture"]} - #{review["verdict"]}"
abort("blocked: #{blocking} critical finding(s)") if review["posture"] == "host-breaking" || blocking > 0
<?php
$files = array_merge(["server.ts"], glob("web/src/*.ts"), ["vite.config.ts"]);
$code = implode("\n\n", array_map(
    fn ($f) => "// file: $f\n" . file_get_contents($f),
    $files
));

$payload = [
    "code"          => $code,
    "mode"          => "review",
    "concern"       => "general",
    "context"       => "CI gate on the default branch; target host is Claude Desktop.",
    "prescan_facts" => ["resources" => [], "flags" => []],
];

// Same tree, same key: a retried job replays the first review instead of buying a second.
$digest = substr(hash("sha256", implode(" ", [
    $payload["code"], $payload["mode"], $payload["concern"], $payload["context"],
])), 0, 16);
$key = "mcp-app-clinic:ci:$digest"; // pass this as the Idempotency-Key header

$started = api("POST", "/run", $payload);
do {
    sleep(2);
    $job = api("GET", "/jobs/" . $started["job_id"]);
} while ($job["status"] === "running");

if ($job["status"] === "failed") {
    fwrite(STDERR, ($job["error"] ?? "run failed") . "\n");
    exit(1);
}

$review = json_decode($job["output"]["output"], true);
file_put_contents("review.json", json_encode($review, JSON_PRETTY_PRINT));

$blocking = 0;
foreach ($review["findings"] as $f) {
    if ($f["priority"] === "critical") { $blocking++; }
    if (in_array($f["priority"], ["critical", "high"], true)) {
        echo "::error title={$f['id']} {$f['category']}::{$f['resource']}: {$f['problem']}\n";
    }
}

echo "{$review['review_name']}: {$review['posture']} - {$review['verdict']}\n";
if ($review["posture"] === "host-breaking" || $blocking > 0) {
    exit(1);
}
using System.Security.Cryptography;
using System.Text;

var files = new[] { "server.ts" }
    .Concat(Directory.GetFiles("web/src", "*.ts").OrderBy(p => p))
    .Append("vite.config.ts");
var code = string.Join("\n\n", files.Select(f => $"// file: {f}\n{File.ReadAllText(f)}"));

const string mode = "review", concern = "general";
const string ctx = "CI gate on the default branch; target host is Claude Desktop.";

var payload = new {
    code, mode, concern, context = ctx,
    prescan_facts = new { resources = Array.Empty<object>(), flags = Array.Empty<object>() },
};

// Same tree, same key: a retried job replays the first review instead of buying a second.
var sum = SHA256.HashData(Encoding.UTF8.GetBytes(string.Join(" ", code, mode, concern, ctx)));
var key = "mcp-app-clinic:ci:" + Convert.ToHexString(sum).ToLowerInvariant()[..16];

var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", key);
// ... send, read data.job_id, poll /jobs/{id} as in step 4 ...

var review = JsonDocument.Parse(rawText!).RootElement;
var blocking = 0;
foreach (var f in review.GetProperty("findings").EnumerateArray())
{
    var priority = f.GetProperty("priority").GetString();
    if (priority == "critical") blocking++;
    if (priority is "critical" or "high")
        Console.WriteLine($"::error title={f.GetProperty("id")} {f.GetProperty("category")}::" +
                          $"{f.GetProperty("resource")}: {f.GetProperty("problem")}");
}

var posture = review.GetProperty("posture").GetString();
Console.WriteLine($"{review.GetProperty("review_name")}: {posture}");
if (posture == "host-breaking" || blocking > 0)
{
    Console.Error.WriteLine(review.GetProperty("verdict").GetString());
    Environment.Exit(1);
}

Two refinements once the gate is green. Send the flags your own linter already knows about in prescan_facts.flags and assert that every one comes back addressed: true in coverage_check — anything set aside is a deliberate decision that deserves a line in the PR. And for a repository that has finished its port, assert migration_map == []: a row reappearing means an OpenAI Apps SDK pattern was copied back in.