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.
| 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 — most often a guest token trying to start a metered run. |
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. 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
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
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 field | Type | Notes |
|---|---|---|
code | string, required | The 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. |
mode | string | review | 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. |
concern | string | general | 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. |
context | string, optional | Extra 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_facts | object, optional | What 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_note | string, optional | Only 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
/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.
| Field | Type | Meaning |
|---|---|---|
review_name | string | A short title naming the app, taken from the code's own naming — e.g. pizza map — half-migrated OpenAI widget. |
posture | string | host-ready | hardening-recommended | host-breaking. See the table below. |
verdict | string | One sentence justifying the posture and naming the single most important change. |
exec_summary | string | Two to four paragraphs, separated by blank lines, on the dominant themes across the app. |
assumptions | string[] | Explicit assumptions filling gaps the paste left open. Read these first — a wrong assumption invalidates the findings built on it. |
open_questions | string[] | Questions whose answers would change the ranking. |
inventory | array | {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. |
findings | array | The prioritized findings table — ids MA-001, MA-002, … in sequence, at least one entry. Columns are listed below. |
migration_map | array | {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_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} — 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. |
summary | string | Closing paragraph you can paste into a ticket: what to fix first, and what risk remains after that. |
The three posture values:
| posture | What it means |
|---|---|
host-ready | No 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-recommended | It 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-breaking | Something 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:
| Column | Meaning |
|---|---|
id | Sequential MA-001, MA-002, … — the stable handle referenced from focus_areas[].finding_ids and from coverage_check[].note. |
category | lifecycle | csp | server-wiring | host-integration | migration | build | hygiene. Weighted by the concern 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 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. |
resource | The 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. |
problem | What is wrong, quoting the construct from this code specifically. |
impact | What 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. |
fix | The concrete change to make — not "handle this properly". |
snippet | A 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:
| Case | What you get |
|---|---|
| A pattern with an MCP Apps equivalent | pattern 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 yet | replacement 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:
| 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.resources are not reconciled here — they shape the inventory instead. |
addressed: true | The flag is covered by the review; 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 app (an origin that only appears in a documentation URL, a localStorage call inside an isMcpApp ? … : … standalone-mode branch). |
| Nothing sent | Omit 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
/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.
| 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", "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.