Flutter Clinic — API

Review Flutter code from your own tools — verdict, area ratings, defects, fixes, back as plain text.

API tokens Open the app

Review Flutter code from CI, a pre-commit hook or a bot — not from a browser tab

Flutter Clinic splits a review in two. The deterministic half — guessing the state management solution, counting widget classes and build() methods, and matching the classic Flutter patterns (credentials in Dart source, plain http:// endpoints, BuildContext used after an await, subscriptions with no teardown, eagerly built lists, hardcoded colours, missing const, bang operators, broad catches) with line numbers — runs in the browser, free, in /dartscan.js. This API is the other half: the metered review that reads the code, decides which of those hits is real and which is a false positive, finds what the pattern scan can never find, and returns a verdict, seven area ratings, and every defect carrying where it is, the problem with its impact, and the fix to apply.

It is library-agnostic. The review works out whether the code is on BLoC, Riverpod, Provider, GetX, MobX, Signals, ChangeNotifier or plain setState and reviews against that solution's own conventions. It will not tell your CI job to rewrite the app in something else.

The reply is plain text, not JSON. Four tagged header lines and six ## sections, in a fixed order. That contract is documented in full below, and /report.js is a vendored parser for it you can lift straight into your pipeline.

Basics

Base URL https://api.skillsafe.ai/v1/app-api. Every response is the same envelope: {"ok":true,"data":{...}} on success, {"ok":false,"error":{"code":"...","message":"...","details":{...}}} on failure. Check ok before touching data.

POST /guest GET /me POST /estimate POST /run GET /jobs/{id} POST /run-stream

There is no app slug in the path. The routes are exactly the ones above — there is no /apps/{slug}/ segment anywhere. The slug is bound to the token, once, by POST /guest with a body of {"slug":"flutter-clinic"}. Every later call just sends that token as a bearer and the platform already knows which app it belongs to.

Money. Credits are ten-thousandths of a dollar. The app runs on the gpt-terra alias, which resolves to gpt-5.6-terra, at markup_bps: 1000 — you pay the model's metered cost plus the publisher's 10%, and nothing to open the page. /estimate is free and creates no job.

Error codes

HTTPcodeWhat to do
400validation_errorThe body is not the shape the app expects. On /guest this is almost always a missing slug in the body — an X-App-Slug header is not accepted. On /run and /estimate it is almost always the input wrapped in an {"input": {...}} envelope, which this API does not take: post the input object itself.
401unauthorizedNo bearer token, or an expired guest token. Mint a new one with POST /guest, or take a personal one from /tokens.html.
402payment_requiredThe balance is below min_credits. Call /estimate first and check it against /me: a 402 after submit is a client bug, not a user error.
404not_foundWrong slug on /guest, or a job id that does not belong to this token. Note that a path like /apps/flutter-clinic/run also lands here — there is no slug path segment.
409conflictThe request conflicts with existing state. Note that a reused Idempotency-Key does not 409: it replays, answering {"job_id", "deduped": true} with the original job even when the body has changed. Change the key whenever you want a new answer.
429rate_limitedBack off and retry with the same idempotency key; do not tight-loop.
500599internalTransient platform or upstream failure. Retry with the same idempotency key so a run that actually started is not billed twice.
A reply that does not satisfy the output contract is not an HTTP error: the run succeeds and returns text your parser rejects. That is a client-side condition, and the app handles it with one automatic reformat retry — see retry_note in the input table and step 6.

Step 1 · A tiny client, and a token

Two ways in. A personal token is the one this browser already holds — open /tokens.html and press Copy shell export, so you never have to open a DevTools console or dig through storage by hand. A guest token is minted by POST /guest with the slug in the body; that call is what binds the token to this app. Guests can call /me and /estimate freely and can run only when the app sponsors them — /estimate reports that as sponsor_enabled.

# Option A - take the token this browser already has: open /tokens.html,
# press "Copy shell export", and paste the line it gives you.
export SKILLSAFE_TOKEN="aut_..."

# Option B - mint a guest token. The slug goes in the BODY, and this is the
# only call that ever mentions it. An X-App-Slug header is not accepted and
# answers 400 "slug is required".
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
  -H "Content-Type: application/json" \
  -d '{"slug":"flutter-clinic"}'
# -> {"ok":true,"data":{"token":"aut_...","subject_type":"guest","credits":0}}

# Every later call sends it as a bearer token, and the path never names the app:
#   -H "Authorization: Bearer $SKILLSAFE_TOKEN"
import json, urllib.request

BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "flutter-clinic"
TOKEN = "YOUR_TOKEN"          # from /tokens.html, or minted below


def call(path, body=None, method=None, token=None):
    """Every endpoint in this API is JSON in, {data}/{error} out."""
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data,
                                 method=method or ("POST" if data else "GET"))
    req.add_header("Content-Type", "application/json")
    if token:
        req.add_header("Authorization", "Bearer " + token)
    with urllib.request.urlopen(req) as r:
        payload = json.load(r)
    if not payload.get("ok"):
        raise RuntimeError(payload.get("error", {}).get("code", "unknown"))
    return payload["data"]


if TOKEN == "YOUR_TOKEN":
    TOKEN = call("/guest", {"slug": SLUG})["token"]   # slug in the body, not a header
print(TOKEN[:12] + "...")
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "flutter-clinic";
// Read the token from a constant or an injected global - never from a Node env object.
let TOKEN = globalThis.SKILLSAFE_TOKEN || "YOUR_TOKEN";

async function call(path, body, method) {
  const res = await fetch(BASE + path, {
    method: method || (body ? "POST" : "GET"),
    headers: {
      "Content-Type": "application/json",
      ...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {})
    },
    body: body ? JSON.stringify(body) : undefined
  });
  const payload = await res.json();
  if (!payload.ok) throw new Error(payload.error.code + ": " + payload.error.message);
  return payload.data;
}

if (TOKEN === "YOUR_TOKEN") {
  TOKEN = "";                                  // no bearer on the guest call
  TOKEN = (await call("/guest", { slug: SLUG })).token;
}
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"os"
)

const base = "https://api.skillsafe.ai/v1/app-api"
const slug = "flutter-clinic"

// os.Getenv keeps the secret out of the source; a literal works just as well.
var token = os.Getenv("SKILLSAFE_TOKEN") // from /tokens.html, or minted by guest()

type envelope struct {
	OK    bool            `json:"ok"`
	Data  json.RawMessage `json:"data"`
	Error *struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"error"`
}

func call(path string, body any, method string) (json.RawMessage, error) {
	var rdr io.Reader
	if body != nil {
		b, _ := json.Marshal(body)
		rdr = bytes.NewReader(b)
		if method == "" {
			method = "POST"
		}
	}
	if method == "" {
		method = "GET"
	}
	req, _ := http.NewRequest(method, base+path, rdr)
	req.Header.Set("Content-Type", "application/json")
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	var env envelope
	if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
		return nil, err
	}
	if !env.OK {
		return nil, errors.New(env.Error.Code + ": " + env.Error.Message)
	}
	return env.Data, nil
}

func guest() error {
	raw, err := call("/guest", map[string]string{"slug": slug}, "")
	if err != nil {
		return err
	}
	var out struct{ Token string }
	if err := json.Unmarshal(raw, &out); err != nil {
		return err
	}
	token = out.Token
	return nil
}
import java.net.URI;
import java.net.http.*;

public class FlutterClinic {
  static final String BASE = "https://api.skillsafe.ai/v1/app-api";
  static final String SLUG = "flutter-clinic";
  static String token = "YOUR_TOKEN";     // from /tokens.html, or minted by guest()
  static final HttpClient HTTP = HttpClient.newHttpClient();

  static String call(String path, String jsonBody, String method) throws Exception {
    HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
        .header("Content-Type", "application/json");
    if (token != null && !token.isEmpty() && !token.equals("YOUR_TOKEN"))
      b.header("Authorization", "Bearer " + token);
    if (jsonBody != null) b.method(method == null ? "POST" : method,
        HttpRequest.BodyPublishers.ofString(jsonBody));
    else b.method(method == null ? "GET" : method, HttpRequest.BodyPublishers.noBody());
    HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
    if (res.statusCode() >= 400) throw new RuntimeException(res.body());
    return res.body();   // {"ok":true,"data":{...}} - parse with your JSON library
  }

  static void guest() throws Exception {
    token = "";
    String body = call("/guest", "{\"slug\":\"" + SLUG + "\"}", null);
    token = body.split("\"token\":\"")[1].split("\"")[0];
  }
}
require "json"
require "net/http"

BASE = URI("https://api.skillsafe.ai/v1/app-api")
SLUG = "flutter-clinic"
TOKEN = ENV["SKILLSAFE_TOKEN"]   # from /tokens.html, or minted below

def call(path, body = nil, method: nil, token: TOKEN)
  uri = URI(BASE.to_s + path)
  klass = method == "DELETE" ? Net::HTTP::Delete : (body ? Net::HTTP::Post : Net::HTTP::Get)
  req = klass.new(uri, "Content-Type" => "application/json")
  req["Authorization"] = "Bearer #{token}" if token && !token.empty?
  req.body = JSON.dump(body) if body
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
  payload["data"]
end

TOKEN2 = TOKEN || call("/guest", { "slug" => SLUG }, token: nil)["token"]
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "flutter-clinic";
$token = getenv("SKILLSAFE_TOKEN") ?: "";   // from /tokens.html, or minted below

function call(string $path, $body = null, ?string $method = null) {
    global $token;
    $headers = ["Content-Type: application/json"];
    if ($token !== "") $headers[] = "Authorization: Bearer $token";
    $ch = curl_init(BASE . $path);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_CUSTOMREQUEST => $method ?? ($body === null ? "GET" : "POST"),
    ]);
    if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    $payload = json_decode(curl_exec($ch), true);
    curl_close($ch);
    if (empty($payload["ok"])) {
        throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
    }
    return $payload["data"];
}

if ($token === "") {
    $token = call("/guest", ["slug" => SLUG])["token"];   // slug in the body
}
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

class FlutterClinic {
  const string Base = "https://api.skillsafe.ai/v1/app-api";
  const string Slug = "flutter-clinic";
  static string Token = "YOUR_TOKEN";       // from /tokens.html, or minted by GuestAsync()
  static readonly HttpClient Http = new();

  static async Task<JsonElement> CallAsync(string path, object? body = null, HttpMethod? method = null) {
    var req = new HttpRequestMessage(method ?? (body is null ? HttpMethod.Get : HttpMethod.Post), Base + path);
    if (body is not null)
      req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
    if (Token is { Length: > 0 } and not "YOUR_TOKEN")
      req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
    var res = await Http.SendAsync(req);
    var payload = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
    if (!payload.GetProperty("ok").GetBoolean())
      throw new Exception(payload.GetProperty("error").GetProperty("code").GetString());
    return payload.GetProperty("data");
  }

  static async Task GuestAsync() {
    Token = "";
    Token = (await CallAsync("/guest", new { slug = Slug })).GetProperty("token").GetString()!;
  }
}

Step 2 · Check who you are and what you can spend

GET /me tells you the subject type, the balance, and the app's own model and markup. Compare the balance against /estimate before you submit — a 402 after submit is a client-side failure, not a user error.

curl -s https://api.skillsafe.ai/v1/app-api/me \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# -> {"ok":true,"data":{
#      "subject_type":"user","credits":184203,"app":{"slug":"flutter-clinic",
#      "model":"gpt-terra","markup_bps":1000,"price_credits":0}}}
#
# credits are in ten-thousandths of a dollar: 184203 = $18.42.
# subject_type is "user" for a personal token and "guest" for a minted one.
me = call("/me", token=TOKEN)
print(me["subject_type"], me["credits"] / 10000, "USD")
const me = await call("/me");
console.log(me.subject_type, me.credits / 10000, "USD");
raw, err := call("/me", nil, "")
// raw is {"subject_type":"user","credits":184203,...}
String me = call("/me", null, null);
System.out.println(me);
me = call("/me")
puts "#{me['subject_type']} #{me['credits'] / 10_000.0} USD"
$me = call("/me");
printf("%s %.2f USD\n", $me["subject_type"], $me["credits"] / 10000);
var me = await CallAsync("/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits").GetInt32() / 10000.0} USD");

Step 3 · Price the run, and prove the model binding

POST /estimate costs nothing and creates no job. Its body is the input object itself — the same one /run takes, documented under The input below. It returns model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled. On this app model reads gpt-5.6-terra, model_alias reads gpt-terra and markup_bps is 1000 (10%). Assert on those three in CI: they are the authoritative proof that the app is wired to the right model at the right markup. hold_credits is what gets reserved — it prices the full output cap and is usually far more than you end up paying.

# body.json is the INPUT OBJECT ITSELF - not {"input": {...}}:
#   {"code":"...","notes":"...","focus":"Full review","facts":"..."}
#
# /estimate is free: no job is created, no credits are held, nothing is charged.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d @body.json
# -> {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#      "markup_bps":1000,"hold_credits":2860,"min_credits":380,
#      "sponsor_enabled":false}}
#
# hold_credits is RESERVED, not charged: it prices the full output cap. What you
# pay is charged_credits on the finished job, and it is usually far lower.
# sponsor_enabled tells you whether a guest token may run at all.
body = {
    "code": open("lib/features/orders/order_tile.dart").read(),
    "notes": "Row widget in an order-history list; the project is on Provider.",
    "focus": "Security and data handling",
}
est = call("/estimate", body, token=TOKEN)          # free, no job created
assert est["model"] == "gpt-5.6-terra"
assert est["model_alias"] == "gpt-terra"
assert est["markup_bps"] == 1000
print("reserved up to", est["hold_credits"] / 10000, "USD")
if me["credits"] < est["min_credits"]:
    raise SystemExit("top up first - a 402 after submit is a client bug, not a user error")
const body = {
  code: source,                       // the code to review, as a string
  notes: "Riverpod screen; the repository is injected in a provider.",
  focus: "Full review"
};
const est = await call("/estimate", body);              // free, no job created
console.assert(est.model_alias === "gpt-terra" && est.markup_bps === 1000);
if (me.credits < est.min_credits) throw new Error("top up first");
body := map[string]any{
	"code":  source,
	"notes": "Long order list; profiling shows jank on scroll.",
	"focus": "Performance",
}
raw, err = call("/estimate", body, "")
var est struct {
	Model       string `json:"model"`
	ModelAlias  string `json:"model_alias"`
	MarkupBps   int    `json:"markup_bps"`
	HoldCredits int    `json:"hold_credits"`
	MinCredits  int    `json:"min_credits"`
	Sponsored   bool   `json:"sponsor_enabled"`
}
json.Unmarshal(raw, &est)
// est.Model == "gpt-5.6-terra", est.ModelAlias == "gpt-terra", est.MarkupBps == 1000
// bodyJson is the input object itself: {"code":"...","focus":"Full review",...}
String est = call("/estimate", bodyJson, null);
// assert est.contains("\"model_alias\":\"gpt-terra\"");
// assert est.contains("\"markup_bps\":1000");
body = {
  "code" => source,
  "notes" => "flutter_bloc feature; the cubit is covered by unit tests.",
  "focus" => "State management"
}
est = call("/estimate", body)
raise "wrong model" unless est["model_alias"] == "gpt-terra" && est["markup_bps"] == 1000
puts "reserved up to #{est['hold_credits'] / 10_000.0} USD"
$body = [
    "code" => $source,
    "notes" => "Settings screen; theming and l10n land next sprint.",
    "focus" => "Accessibility and theming",
];
$est = call("/estimate", $body);
assert($est["model_alias"] === "gpt-terra" && $est["markup_bps"] === 1000);
var body = new {
  code = source,
  notes = "MobX store plus the view that observes it.",
  focus = "Full review"
};
var est = await CallAsync("/estimate", body);
if (est.GetProperty("model_alias").GetString() != "gpt-terra") throw new Exception("wrong model");

Step 4 · Run the review and poll for it

POST /run is metered and returns {"job_id":"job_..."}; poll GET /jobs/{job_id} until status is succeeded or failed. The body is the input object directly, exactly as for /estimate. Always send an Idempotency-Key request header derived from the input — a network blip or a retry with the same key returns the same job instead of billing twice.

Key it from the input, never from a clock. A good shape is flutter-clinic:<inputhash>:a1. Because the hash comes from the input, every transport retry of one attempt replays the same job. The reformat retry — the one extra run you make when the first reply does not satisfy the output contract, carrying retry_note — is a different input, so it needs a different key: reuse the same base and bump the attempt counter to :a2 (the page itself appends -reformat to the same base, which does the same job without ever colliding with the key a later deliberate re-run would use). That way a malformed first reply can never double-bill the identical attempt, and the two runs stay separable in the ledger. Reusing the key here would not save money — it would replay the original job and hand you back the same malformed reply the retry exists to replace.

output.output on the finished job is the plain-text review. It is a string, not JSON — do not call a JSON parser on it. Step 6 splits it.

# Metered. Always send Idempotency-Key: a retry with the same key returns the
# same job instead of billing twice. Derive it from the input, not from a clock.
HASH=$(python3 -c 'import hashlib;print(hashlib.sha256(open("body.json","rb").read()).hexdigest()[:16])')
KEY="flutter-clinic:$HASH:a1"     # the reformat retry sends :a2 with retry_note

JOB=$(curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @body.json | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll until terminal.
while :; do
  OUT=$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" \
    -H "Authorization: Bearer $SKILLSAFE_TOKEN")
  ST=$(echo "$OUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["status"])')
  [ "$ST" = "succeeded" ] || [ "$ST" = "failed" ] && break
  sleep 2
done
echo "$OUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["output"]["output"])'
# The output field is PLAIN TEXT - the review contract documented below.
import hashlib, time

digest = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:16]
key = "flutter-clinic:" + digest + ":a1"
req = urllib.request.Request(BASE + "/run", data=json.dumps(body).encode(), method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", key)          # a retry with this key never double-bills
with urllib.request.urlopen(req) as r:
    job_id = json.load(r)["data"]["job_id"]

while True:
    job = call("/jobs/" + job_id, token=TOKEN)
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(2)

reply = job["output"]["output"]                 # plain text, NOT json.loads
review = parse_review(reply)                    # step 6
print(review["verdict"], review["confidence"], len(review["blocking"]), "blocking")
print("charged", job.get("charged_credits", 0) / 10000, "USD")
const enc = new TextEncoder().encode(JSON.stringify(body));
const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", enc))]
  .map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16);

const started = await fetch(BASE + "/run", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: "Bearer " + TOKEN,
    "Idempotency-Key": "flutter-clinic:" + digest + ":a1"
  },
  body: JSON.stringify(body)
}).then(r => r.json());

let job;
do {
  await new Promise(r => setTimeout(r, 2000));
  job = await call("/jobs/" + started.data.job_id);
} while (job.status !== "succeeded" && job.status !== "failed");

const review = parseReview(job.output.output);   // plain text in, object out - step 6
console.log(review.verdict, review.blocking.length, "blocking defects");
// POST /run needs the Idempotency-Key header, so build the request directly.
b, _ := json.Marshal(body)
sum := sha256.Sum256(b)
idemKey := "flutter-clinic:" + hex.EncodeToString(sum[:8]) + ":a1"
req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", idemKey)
res, _ := http.DefaultClient.Do(req)
// decode {"data":{"job_id":"..."}} then poll GET /jobs/{id} until status is
// terminal; data.output.output is the plain-text review, not JSON.
String key = "flutter-clinic:" + Integer.toHexString(bodyJson.hashCode()) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer " + token)
    .header("Idempotency-Key", key)
    .POST(HttpRequest.BodyPublishers.ofString(bodyJson))
    .build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
// extract job_id, then poll GET /jobs/{id} every two seconds until terminal.
// data.output.output is a STRING holding the review - feed it to parseReview().
require "digest"

key = "flutter-clinic:#{Digest::SHA256.hexdigest(JSON.dump(body))[0, 16]}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json",
                          "Authorization" => "Bearer #{TOKEN2}",
                          "Idempotency-Key" => key)
req.body = JSON.dump(body)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]

job = nil
loop do
  job = call("/jobs/#{job_id}", token: TOKEN2)
  break if %w[succeeded failed].include?(job["status"])
  sleep 2
end
review = parse_review(job["output"]["output"])   # plain text - step 6
puts review[:verdict]
$key = "flutter-clinic:" . substr(hash("sha256", json_encode($body)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode($body),
    CURLOPT_HTTPHEADER => [
        "Content-Type: application/json",
        "Authorization: Bearer $token",
        "Idempotency-Key: $key",
    ],
]);
$jobId = json_decode(curl_exec($ch), true)["data"]["job_id"];
curl_close($ch);

do {
    sleep(2);
    $job = call("/jobs/$jobId");
} while (!in_array($job["status"], ["succeeded", "failed"], true));
$review = parse_review($job["output"]["output"]);   // plain text - step 6
var json = JsonSerializer.Serialize(body);
var key = "flutter-clinic:" + Convert.ToHexString(
    System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(json)))[..16] + ":a1";

var run = new HttpRequestMessage(HttpMethod.Post, Base + "/run") {
  Content = new StringContent(json, Encoding.UTF8, "application/json")
};
run.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
run.Headers.Add("Idempotency-Key", key);
var started = JsonDocument.Parse(await (await Http.SendAsync(run)).Content.ReadAsStringAsync()).RootElement;
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();

JsonElement job;
do {
  await Task.Delay(2000);
  job = await CallAsync("/jobs/" + jobId);
} while (job.GetProperty("status").GetString() is not ("succeeded" or "failed"));

var reply = job.GetProperty("output").GetProperty("output").GetString()!;   // plain text

Step 5 · Or stream it

POST /run-stream is the same metered run over server-sent events, and it is what the app itself uses. Same body, same Idempotency-Key header. Frame names arrive on the event: line, not as a type field inside the payload — switch on the event name. The job frame carries the job_id and arrives first; every delta frame carries a text fragment, and concatenating them in order rebuilds the review; the terminal frame (done, or error when the run failed) carries status, charged_credits and truncated.

Prefer the done payload's own output.output when it is present — that is what the app does, because an SSE stream can drop its tail and a review missing its last section fails the contract for no good reason. If truncated is true the balance capped the output: render what parsed and say so, rather than presenting a half-finished review as complete.

# Server-sent events. Frame names arrive on the `event:` line, not as a field in
# the payload - switch on the event name, not on data.type.
curl -N -s -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @body.json
#
# event: job      data: {"job_id":"job_..."}
# event: delta    data: {"text":"VERDICT: Rework\nSTACK: Py"}
# event: delta    data: {"text":"thon\nCONFIDENCE: 88\nSUMMARY: ..."}
# event: done     data: {"status":"succeeded","charged_credits":1740,"truncated":false}
#
# A failed run ends on `event: error` with {"code":"...","message":"..."} instead.
# Concatenate every delta.text in order: the result is the plain-text review. The
# `done` frame also carries output.output when the run finished - prefer it, an
# SSE stream can drop its tail. truncated:true means the balance capped the reply.
req = urllib.request.Request(BASE + "/run-stream", data=json.dumps(body).encode(), method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", key)

raw, event, job_id, final = "", None, None, None
with urllib.request.urlopen(req) as stream:
    for line in stream:
        line = line.decode().rstrip("\n")
        if line.startswith("event: "):
            event = line[7:].strip()             # the frame NAME lives here
        elif line.startswith("data: "):
            payload = json.loads(line[6:])
            if event == "job":
                job_id = payload.get("job_id")
            elif event == "delta":
                raw += payload.get("text", "")
            elif event == "done":
                final = (payload.get("output") or {}).get("output")
                if payload.get("truncated"):
                    print("cut short by the balance - showing what arrived")
            elif event == "error":
                raise RuntimeError(payload.get("code", "internal"))

review = parse_review(final or raw)              # the done payload is authoritative
const res = await fetch(BASE + "/run-stream", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: "Bearer " + TOKEN,
    "Idempotency-Key": "flutter-clinic:" + digest + ":a1"
  },
  body: JSON.stringify(body)
});

const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, final = null;
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  buf += dec.decode(value, { stream: true });
  const lines = buf.split("\n");
  buf = lines.pop();
  for (const line of lines) {
    if (line.startsWith("event: ")) event = line.slice(7).trim();
    else if (line.startsWith("data: ")) {
      const p = JSON.parse(line.slice(6));
      if (event === "delta") raw += p.text || "";
      if (event === "done") {
        final = p.output && p.output.output;
        if (p.truncated) console.warn("truncated - keep the partial and say so");
      }
      if (event === "error") throw new Error(p.code + ": " + p.message);
    }
  }
}
const review = parseReview(final || raw);        // the done payload is authoritative
req, _ = http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", idemKey)
res, _ = http.DefaultClient.Do(req)
defer res.Body.Close()

sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
var raw, event string
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event: "):
		event = strings.TrimSpace(line[7:])
	case strings.HasPrefix(line, "data: ") && event == "delta":
		var d struct{ Text string }
		json.Unmarshal([]byte(line[6:]), &d)
		raw += d.Text
	}
}
// raw is the plain-text review; hand it to parseReview (step 6). The `done`
// frame's output.output carries the same text and is safer against a lost tail.
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer " + token)
    .header("Idempotency-Key", key)
    .POST(HttpRequest.BodyPublishers.ofString(bodyJson))
    .build();

StringBuilder raw = new StringBuilder();
String[] event = { null };
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
  if (line.startsWith("event: ")) event[0] = line.substring(7).trim();
  else if (line.startsWith("data: ") && "delta".equals(event[0])) {
    String d = line.substring(6);
    int i = d.indexOf("\"text\":\"");
    if (i >= 0) raw.append(d.substring(i + 8, d.lastIndexOf("\"")));   // use a JSON library
  }
});
// raw holds the review text once every delta has been unescaped properly.
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json",
                          "Authorization" => "Bearer #{TOKEN2}",
                          "Idempotency-Key" => key)
req.body = JSON.dump(body)

raw = ""
final = nil
event = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      chunk.each_line do |line|
        line.chomp!
        if line.start_with?("event: ") then event = line[7..].strip
        elsif line.start_with?("data: ")
          p = JSON.parse(line[6..])
          raw << (p["text"] || "") if event == "delta"
          final = p.dig("output", "output") if event == "done"
        end
      end
    end
  end
end
review = parse_review(final || raw)
$raw = "";
$final = null;
$event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode($body),
    CURLOPT_HTTPHEADER => [
        "Content-Type: application/json",
        "Authorization: Bearer $token",
        "Idempotency-Key: $key",
    ],
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$final, &$event) {
        foreach (explode("\n", $chunk) as $line) {
            if (str_starts_with($line, "event: ")) {
                $event = trim(substr($line, 7));
            } elseif (str_starts_with($line, "data: ")) {
                $p = json_decode(substr($line, 6), true);
                if ($event === "delta") $raw .= $p["text"] ?? "";
                if ($event === "done") $final = $p["output"]["output"] ?? null;
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);
$review = parse_review($final ?? $raw);
var stream = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream") {
  Content = new StringContent(json, Encoding.UTF8, "application/json")
};
stream.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
stream.Headers.Add("Idempotency-Key", key);

using var res2 = await Http.SendAsync(stream, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res2.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null, line;
while ((line = await reader.ReadLineAsync()) is not null) {
  if (line.StartsWith("event: ")) evt = line[7..].Trim();
  else if (line.StartsWith("data: ") && evt == "delta")
    raw.Append(JsonDocument.Parse(line[6..]).RootElement.GetProperty("text").GetString());
}
var review = ParseReview(raw.ToString());

Step 6 · Parse the reply

The reply is plain text. Split it in two passes: read the four tagged header lines off the top, then walk the ## headings and collect the - bullets underneath each. Bullets in the three defect sections split on " | " into exactly four fields; the seven under ## Area health split into three (area, rating, note). A defect or list section with nothing to report holds the single bullet - None., which decodes to an empty list; the area table never uses that form.

Validate before you trust it. If any header line is missing, the confidence is not an integer in 0–100, or any of the six sections is absent, the reply failed the contract: retry once with retry_note set (a different idempotency key — bump to :a2), and if the second attempt fails too, surface the raw text rather than a half-parsed review. That is what the app does: it re-runs once under its own -reformat key, tells you in the panel that this is one extra run, and falls back to the raw reply if the second attempt misses the contract as well.

# Header lines off the top:
VERDICT=$(sed -n 's/^VERDICT: *//p'    review.txt | head -1)
STACK=$(sed -n 's/^STACK: *//p'  review.txt | head -1)
CONF=$(sed -n 's/^CONFIDENCE: *//p'    review.txt | head -1)

# The summary: everything after "SUMMARY: " up to the first blank line.
SUMMARY=$(awk '/^SUMMARY: /{f=1;sub(/^SUMMARY: /,"")} f&&NF{printf "%s ",$0} f&&!NF{exit}' review.txt)

# One section's bullets, e.g. the blocking defects:
awk '/^## Blocking defects/{f=1;next} /^## /{f=0} f&&/^- /' review.txt

# The seven area ratings: three fields each.
awk '/^## Area health/{f=1;next} /^## /{f=0} f&&/^- /' review.txt |
  sed 's/^- //' | awk -F' \\| ' '{printf "%-28s %-12s %s\n", $1, $2, $3}'

# Split a defect bullet into its four fields:
awk '/^## Blocking defects/{f=1;next} /^## /{f=0} f&&/^- /' review.txt |
  sed 's/^- //' |
  awk -F' \\| ' '{print "defect:  "$1"\nwhere:   "$2"\nproblem: "$3"\nfix:     "$4"\n"}'

# Gate the build on the verdict:
[ "$VERDICT" = "Rework" ] && { echo "blocked: $SUMMARY"; exit 1; }
echo "$VERDICT ($STACK, confidence $CONF)"
import re

SECTIONS = ["Area health", "Blocking defects", "Should fix", "Polish",
            "What's solid", "Next steps"]
KEYS = ["areas", "blocking", "should", "polish", "solid", "next"]


def parse_review(text):
    """Plain text in, dict out. Returns None when the contract is not met."""
    text = text.strip()
    if text.startswith("```"):                       # tolerate a stray fence
        text = re.sub(r"^```[^\n]*\n?", "", text)
        text = re.sub(r"\n?```$", "", text).strip()

    head = {}
    for tag in ("VERDICT", "STACK", "CONFIDENCE"):
        m = re.search(r"^%s:\s*(.+)$" % tag, text, re.M)
        if not m:
            return None
        head[tag.lower()] = m.group(1).strip()

    m = re.search(r"^SUMMARY:\s*([\s\S]*?)(?:\n\s*\n|\n##\s)", text + "\n\n", re.M)
    summary = " ".join(m.group(1).split()) if m else ""

    body, current = {k: None for k in KEYS}, None
    for line in text.split("\n"):
        h = re.match(r"^#{2,3}\s+(.*?)\s*$", line)
        if h:
            name = h.group(1).rstrip(":")
            current = KEYS[SECTIONS.index(name)] if name in SECTIONS else None
            if current:
                body[current] = []
            continue
        if current is not None and line.startswith("- "):
            body[current].append(line[2:].strip())

    if any(v is None for v in body.values()) or not summary:
        return None
    if head["verdict"] not in ("Ship it", "Needs fixes", "Rework", "Not reviewable"):
        return None
    if not re.fullmatch(r"\d{1,3}", head["confidence"]) or int(head["confidence"]) > 100:
        return None

    out = {"verdict": head["verdict"], "stack": head["stack"],
           "confidence": int(head["confidence"]), "summary": summary}
    for key in KEYS:
        items = [b for b in body[key] if b.lower().rstrip(".") != "none"]
        if key == "areas":
            out[key] = [dict(zip(("area", "rating", "note"),
                                 ([p.strip() for p in item.split(" | ")] + ["", "", ""])[:3]))
                        for item in body[key]]
            continue
        if key in ("blocking", "should", "polish"):
            rows = []
            for item in items:
                parts = [p.strip() for p in item.split(" | ")]
                parts += [""] * (4 - len(parts))
                rows.append(dict(zip(("finding", "where", "problem", "fix"), parts[:4])))
            out[key] = rows
        else:
            out[key] = items
    return out


review = parse_review(reply)
if review is None:
    body["retry_note"] = RETRY_NOTE     # then re-run with idempotency key ...:a2
elif review["verdict"] == "Rework":
    raise SystemExit("blocked: " + review["blocking"][0]["finding"])
const SECTIONS = {
  "Area health": "areas", "Blocking defects": "blocking", "Should fix": "should",
  "Polish": "polish", "What's solid": "solid", "Next steps": "next"
};
const VERDICTS = ["Ship it", "Needs fixes", "Rework", "Not reviewable"];

function parseReview(text) {
  let t = String(text || "").trim();
  if (t.startsWith("```")) t = t.replace(/^```[^\n]*\n?/, "").replace(/\n?```$/, "").trim();

  const head = {};
  for (const tag of ["VERDICT", "STACK", "CONFIDENCE"]) {
    const m = t.match(new RegExp("^" + tag + ":\\s*(.+)$", "m"));
    if (!m) return null;
    head[tag.toLowerCase()] = m[1].trim();
  }
  if (!VERDICTS.includes(head.verdict)) return null;
  const conf = Number(head.confidence);
  if (!Number.isInteger(conf) || conf < 0 || conf > 100) return null;

  const lines = t.split("\n");
  const body = {}, summary = [];
  let mode = null;
  for (const line of lines) {
    const h = line.match(/^#{2,3}\s+(.*?)\s*$/);
    if (h) {
      mode = SECTIONS[h[1].replace(/:$/, "")] || null;
      if (mode) body[mode] = [];
      continue;
    }
    const s = line.match(/^SUMMARY:\s*(.*)$/);
    if (s && !summary.length) { if (s[1].trim()) summary.push(s[1].trim()); mode = "__sum__"; continue; }
    if (mode === "__sum__") { if (!line.trim()) { mode = null; continue; } summary.push(line.trim()); continue; }
    if (mode && body[mode] && /^\s*-\s+/.test(line)) body[mode].push(line.replace(/^\s*-\s+/, "").trim());
  }
  if (Object.values(SECTIONS).some(k => !body[k]) || !summary.length) return null;

  const drop = a => a.filter(x => !/^none\.?$/i.test(x));
  const rows = a => drop(a).map(item => {
    const p = item.split(" | ").map(s => s.trim());
    return { finding: p[0] || "", where: p[1] || "General", problem: p[2] || "", fix: p[3] || "" };
  });
  // Area health is three fields, and it never carries "- None."
  const areaRows = a => a.map(item => {
    const p = item.split(" | ").map(s => s.trim());
    return { area: p[0] || "", rating: p[1] || "Not covered", note: p[2] || "" };
  });
  return {
    verdict: head.verdict, stack: head.stack, confidence: conf,
    summary: summary.join(" "),
    areas: areaRows(body.areas), blocking: rows(body.blocking), should: rows(body.should),
    polish: rows(body.polish), solid: drop(body.solid), next: drop(body.next)
  };
}

const review = parseReview(reply);
if (!review) { /* re-run once with retry_note and idempotency key ...:a2 */ }
else if (review.verdict === "Rework") process.exitCode = 1;
// Two passes: the tagged header lines, then the ## sections.
type Finding struct{ Finding, Where, Problem, Fix string }

var sections = map[string]string{
	"Area health": "areas", "Blocking defects": "blocking", "Should fix": "should",
	"Polish": "polish", "What's solid": "solid", "Next steps": "next",
}

func parseReview(text string) (verdict, stack string, confidence int,
	summary string, out map[string][]string, ok bool) {

	out = map[string][]string{}
	var mode string
	var sum []string

	for _, line := range strings.Split(strings.TrimSpace(text), "\n") {
		switch {
		case strings.HasPrefix(line, "## "):
			mode = sections[strings.TrimSuffix(strings.TrimSpace(line[3:]), ":")]
			if mode != "" {
				out[mode] = []string{}
			}
		case strings.HasPrefix(line, "VERDICT: "):
			verdict, mode = strings.TrimSpace(line[9:]), ""
		case strings.HasPrefix(line, "STACK: "):
			stack, mode = strings.TrimSpace(line[7:]), ""
		case strings.HasPrefix(line, "CONFIDENCE: "):
			confidence, _ = strconv.Atoi(strings.TrimSpace(line[12:]))
			mode = ""
		case strings.HasPrefix(line, "SUMMARY: "):
			sum, mode = append(sum, strings.TrimSpace(line[9:])), "sum"
		case mode == "sum" && strings.TrimSpace(line) == "":
			mode = ""
		case mode == "sum":
			sum = append(sum, strings.TrimSpace(line))
		case mode != "" && strings.HasPrefix(line, "- "):
			if item := strings.TrimSpace(line[2:]); !strings.EqualFold(strings.TrimSuffix(item, "."), "none") {
				out[mode] = append(out[mode], item)
			}
		}
	}
	summary = strings.Join(sum, " ")
	ok = verdict != "" && stack != "" && summary != "" && len(out) == 6 &&
		confidence >= 0 && confidence <= 100
	return
}

// A finding bullet splits into exactly four fields:
func splitFinding(item string) Finding {
	p := strings.SplitN(item, " | ", 4)
	for len(p) < 4 {
		p = append(p, "")
	}
	return Finding{p[0], p[1], p[2], p[3]}
}
import java.util.*;

record Finding(String finding, String where, String problem, String fix) {}

static final Map<String, String> SECTIONS = Map.of(
    "Area health", "areas", "Blocking defects", "blocking", "Should fix", "should",
    "Polish", "polish", "What's solid", "solid", "Next steps", "next");

static Map<String, Object> parseReview(String text) {
  Map<String, List<String>> body = new HashMap<>();
  Map<String, Object> head = new HashMap<>();
  StringBuilder summary = new StringBuilder();
  String mode = "";

  for (String line : text.strip().split("\n")) {
    if (line.startsWith("## ")) {
      mode = SECTIONS.getOrDefault(line.substring(3).strip().replaceAll(":$", ""), "");
      if (!mode.isEmpty()) body.put(mode, new ArrayList<>());
    } else if (line.startsWith("VERDICT: "))    { head.put("verdict", line.substring(9).strip());  mode = ""; }
    else if (line.startsWith("STACK: "))     { head.put("stack", line.substring(7).strip()); mode = ""; }
    else if (line.startsWith("CONFIDENCE: "))   { head.put("confidence", Integer.parseInt(line.substring(12).strip())); mode = ""; }
    else if (line.startsWith("SUMMARY: "))      { summary.append(line.substring(9).strip()); mode = "sum"; }
    else if (mode.equals("sum") && line.isBlank()) mode = "";
    else if (mode.equals("sum"))                  summary.append(" ").append(line.strip());
    else if (!mode.isEmpty() && line.startsWith("- ")) {
      String item = line.substring(2).strip();
      if (!item.replaceAll("\\.$", "").equalsIgnoreCase("none")) body.get(mode).add(item);
    }
  }
  if (body.size() != 5 || head.size() != 3 || summary.isEmpty()) return null;  // contract failure
  head.put("summary", summary.toString());
  head.put("sections", body);
  return head;
}

// A finding bullet has exactly four " | "-separated fields:
static Finding splitFinding(String item) {
  String[] p = Arrays.copyOf(item.split(" \\| ", 4), 4);
  for (int i = 0; i < 4; i++) if (p[i] == null) p[i] = "";
  return new Finding(p[0].strip(), p[1].strip(), p[2].strip(), p[3].strip());
}
SECTIONS = {
  "Area health" => :areas, "Blocking defects" => :blocking, "Should fix" => :should,
  "Polish" => :polish, "What's solid" => :solid, "Next steps" => :next
}.freeze
VERDICTS = ["Ship it", "Needs fixes", "Rework", "Not reviewable"].freeze

def parse_review(text)
  t = text.to_s.strip
  head = {}
  %w[VERDICT STACK CONFIDENCE].each do |tag|
    m = t[/^#{tag}:\s*(.+)$/, 1]
    return nil unless m
    head[tag.downcase.to_sym] = m.strip
  end
  return nil unless VERDICTS.include?(head[:verdict])
  return nil unless head[:confidence] =~ /\A\d{1,3}\z/ && head[:confidence].to_i <= 100

  body = {}
  summary = []
  mode = nil
  t.each_line do |line|
    line = line.chomp
    if (h = line[/^\#{2,3}\s+(.*?)\s*$/, 1])
      mode = SECTIONS[h.sub(/:$/, "")]
      body[mode] = [] if mode
    elsif line.start_with?("SUMMARY: ")
      summary << line[9..].strip
      mode = :sum
    elsif mode == :sum
      line.strip.empty? ? mode = nil : summary << line.strip
    elsif mode && body[mode] && line.start_with?("- ")
      item = line[2..].strip
      body[mode] << item unless item.sub(/\.$/, "").casecmp?("none")
    end
  end
  return nil if SECTIONS.values.any? { |k| body[k].nil? } || summary.empty?

  rows = ->(a) { a.map { |i| f = i.split(" | ").map(&:strip)
                            { finding: f[0].to_s, where: f[1] || "General",
                              problem: f[2].to_s, fix: f[3].to_s } } }
  head.merge(confidence: head[:confidence].to_i, summary: summary.join(" "),
             areas: body[:areas], blocking: rows.(body[:blocking]), should: rows.(body[:should]),
             polish: rows.(body[:polish]), solid: body[:solid], next: body[:next])
end
<?php
const SECTIONS = [
    "Area health" => "areas", "Blocking defects" => "blocking", "Should fix" => "should",
    "What's solid" => "solid",
    "Polish" => "polish", "Next steps" => "next",
];
const VERDICTS = ["Ship it", "Needs fixes", "Rework", "Not reviewable"];

function parse_review(string $text): ?array {
    $text = trim($text);
    $head = [];
    foreach (["VERDICT", "STACK", "CONFIDENCE"] as $tag) {
        if (!preg_match("/^$tag:\s*(.+)$/m", $text, $m)) return null;
        $head[strtolower($tag)] = trim($m[1]);
    }
    if (!in_array($head["verdict"], VERDICTS, true)) return null;
    if (!preg_match('/^\d{1,3}$/', $head["confidence"]) || (int) $head["confidence"] > 100) return null;

    $body = [];
    $summary = [];
    $mode = null;
    foreach (explode("\n", $text) as $line) {
        if (preg_match('/^#{2,3}\s+(.*?)\s*$/', $line, $h)) {
            $mode = SECTIONS[rtrim($h[1], ":")] ?? null;
            if ($mode) $body[$mode] = [];
        } elseif (str_starts_with($line, "SUMMARY: ")) {
            $summary[] = trim(substr($line, 9));
            $mode = "sum";
        } elseif ($mode === "sum") {
            if (trim($line) === "") { $mode = null; } else { $summary[] = trim($line); }
        } elseif ($mode && str_starts_with(ltrim($line), "- ")) {
            $item = trim(substr(ltrim($line), 2));
            if (strcasecmp(rtrim($item, "."), "none") !== 0) $body[$mode][] = $item;
        }
    }
    foreach (SECTIONS as $key) if (!isset($body[$key])) return null;
    if (!$summary) return null;

    $rows = function (array $items): array {
        return array_map(function ($item) {
            $p = array_pad(array_map("trim", explode(" | ", $item)), 4, "");
            return ["finding" => $p[0], "where" => $p[1] ?: "General",
                    "problem" => $p[2], "fix" => $p[3]];
        }, $items);
    };
    return $head + [
        "confidence" => (int) $head["confidence"],
        "summary" => implode(" ", $summary),
        "areas" => $body["areas"], "blocking" => $rows($body["blocking"]), "should" => $rows($body["should"]),
        "polish" => $rows($body["polish"]), "solid" => $body["solid"], "next" => $body["next"],
    ];
}
using System.Text.RegularExpressions;

record Finding(string Finding_, string Where, string Problem, string Fix);

static readonly Dictionary<string, string> Sections = new() {
  ["Area health"] = "areas", ["Blocking defects"] = "blocking", ["Should fix"] = "should",
  ["What's solid"] = "solid",
  ["Polish"] = "polish", ["Next steps"] = "next"
};
static readonly string[] Verdicts = { "Ship it", "Needs fixes", "Rework", "Not reviewable" };

static Dictionary<string, object>? ParseReview(string text) {
  var body = new Dictionary<string, List<string>>();
  var summary = new List<string>();
  string verdict = "", stack = "", mode = "";
  int confidence = -1;

  foreach (var line in text.Trim().Split('\n')) {
    var h = Regex.Match(line, @"^#{2,3}\s+(.*?)\s*$");
    if (h.Success) {
      mode = Sections.GetValueOrDefault(h.Groups[1].Value.TrimEnd(':'), "");
      if (mode.Length > 0) body[mode] = new List<string>();
    }
    else if (line.StartsWith("VERDICT: "))    { verdict = line[9..].Trim(); mode = ""; }
    else if (line.StartsWith("STACK: "))   { stack = line[7..].Trim(); mode = ""; }
    else if (line.StartsWith("CONFIDENCE: ")) { int.TryParse(line[12..].Trim(), out confidence); mode = ""; }
    else if (line.StartsWith("SUMMARY: "))    { summary.Add(line[9..].Trim()); mode = "sum"; }
    else if (mode == "sum" && line.Trim().Length == 0) mode = "";
    else if (mode == "sum") summary.Add(line.Trim());
    else if (mode.Length > 0 && line.TrimStart().StartsWith("- ")) {
      var item = line.TrimStart()[2..].Trim();
      if (!string.Equals(item.TrimEnd('.'), "none", StringComparison.OrdinalIgnoreCase))
        body[mode].Add(item);
    }
  }
  if (body.Count != 5 || summary.Count == 0 || !Verdicts.Contains(verdict)
      || confidence < 0 || confidence > 100) return null;   // contract failure

  static Finding Split(string item) {
    var p = item.Split(" | ", 4);
    Array.Resize(ref p, 4);
    return new Finding(p[0] ?? "", p[1] ?? "General", p[2] ?? "", p[3] ?? "");
  }
  return new Dictionary<string, object> {
    ["verdict"] = verdict, ["stack"] = stack, ["confidence"] = confidence,
    ["summary"] = string.Join(" ", summary),
    ["areas"] = body["areas"], ["blocking"] = body["blocking"].Select(Split).ToList(),
    ["should"] = body["should"].Select(Split).ToList(),
    ["polish"] = body["polish"].Select(Split).ToList(),
    ["solid"] = body["solid"], ["next"] = body["next"]
  };
}
You do not have to write that parser. /report.js is the app's own implementation and the single place this contract is decoded — see the last section for how to load it in Node.

The input

One flat object, posted directly as the body of /estimate and /run. There is no input wrapper: {"code": "...", "focus": "Full review"} is the body, and {"input": {"code": ...}} is a 400 validation_error.

FieldTypeNotes
codestring, requiredThe Flutter or Dart code to review: a widget, a screen with its State class, a controller, a repository, a model, or a whole file. The web app clips anything over 60,000 characters by dropping the middle on whole-line boundaries and keeping both ends, with an in-band marker line naming the original line range that was removed - the end of a widget file is where dispose(), the private sub-widgets and the extensions live, so a plain head-truncation throws away exactly the half a Flutter review needs. Driving the API yourself, you choose your own clipping; if you clip, say so in-band. Line references in the reply count from 1 over the text exactly as you send it, so do not renumber or reindent it first.
notesstring, optional, max 6,000 charsWhat the screen or class does, which packages the project uses, what you want checked, and the constraints (“we are stuck on Provider until Q3”, “this is a prototype”, “theming lands next sprint”). Often the highest-value field in the body: it is what stops the review recommending something the team cannot do, and a constraint you state is taken at its word — though a blocking defect is still reported, with your caveat attached in its problem field.
focusstring, requiredExactly one of Full review, Widgets and rebuilds, State management, Performance, Accessibility and theming or Security and data handling. Any other value is a 400. Full review weighs every area; the narrower focuses set where the depth goes and never suppress a blocking defect from another area.
factsstring, optionalPlain text: the output of the browser-side mechanical scan — a state management guess, size and widget counts, and pattern hits with line numbers. A hint, not a verdict. The review cross-checks it against the code: a hit the code does not actually misuse is a false positive and is dropped silently, and a real problem the scan missed still gets reported. You can generate the exact same block yourself with /dartscan.js (last section), or omit the field entirely.
retry_notestring, optionalSent only on the app's automatic one-shot reformat retry, when the first reply failed the parse contract. It restates the required output shape in full. Callers driving the API themselves normally omit it — add it only on a second attempt after a failed parse, and give that attempt its own idempotency key.

A complete body

{
  "code": "import 'package:flutter/material.dart';\n\nclass OrderTile extends StatefulWidget {\n  const OrderTile({super.key, required this.order});\n  final Order order;\n  @override\n  State<OrderTile> createState() => _OrderTileState();\n}\n\nclass _OrderTileState extends State<OrderTile> {\n  static const String authToken = 'Bearer sk_live_9f2c1b7d4e8a';\n  StreamSubscription<String>? _sub;\n\n  @override\n  void initState() {\n    super.initState();\n    _sub = statusFeed().listen((s) => setState(() => _status = s));\n  }\n\n  @override\n  void dispose() {\n    super.dispose();\n  }\n\n  Future<void> _reorder() async {\n    await api.reorder(widget.order.id);\n    Navigator.of(context).pushNamed('/cart');\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return ListTile(\n      title: Text(widget.order.id, style: TextStyle(fontSize: 16)),\n      trailing: IconButton(icon: Icon(Icons.refresh), onPressed: _reorder),\n    );\n  }\n}\n",
  "notes": "Row widget in an order-history list, roughly 40 rows on screen. The project is on Provider and we cannot change that this quarter. Theming lands next sprint.",
  "focus": "Full review",
  "facts": "Mechanical scan of the pasted code (regex over text, not a Dart parse; pattern-matching, not judgement):\n- 33 non-empty lines, 812 chars; reads like Dart or Flutter. 1 widget class(es), 1 build() method(s), longest 6 lines, a dispose() is present.\n- State management guess: plain setState.\n- Pattern hits: 1 possible credential(s) in Dart source (line 11); 1 possible BuildContext use(s) after an await, with no mounted check in the file (line 26); 1 subscription(s)/controller(s) with no visible teardown (line 17); 1 hardcoded colour(s)/text style(s) (line 32); 1 IconButton(s) with no tooltip (line 33)."
}
Send the code as it is. The reply cites Line 26 or build() of _OrderTileState, and those references are only useful if the text you sent is the text you have in front of you.

The output contract

The model returns plain text — not JSON, and with no code fence around the whole response. Four tagged header lines, then six ## sections in a fixed order. These are the rules the app's own render path enforces, so a client that parses the same way will not be surprised:

Cross-rules the verdict must satisfy

VerdictWhat must hold
Ship itBlocking defects and Should fix are both - None. Polish items and next steps may still be present.
Needs fixesShould-fix defects exist that a competent author can clear before merging.
ReworkBlocking defects holds at least one defect.
Not reviewableAll three defect sections are - None., every area row is Not covered, and the summary says what was supplied instead of reviewable Dart.

The verdict follows the defects, never the reverse. If a reply breaks one of these, the safe reading is the defects: a “Ship it” that still lists a blocking defect should be treated as “Needs fixes” until you have checked it, which is exactly what the app flags on screen. The app also flags an area rated Strong that carries a blocking defect, because the ratings table is the part people skim.

A complete reply

VERDICT: Rework
STACK: plain setState StatefulWidget, Provider elsewhere in the project
CONFIDENCE: 82
SUMMARY: A list row widget that subscribes to a status feed and can reorder its order. A live-looking
bearer token sits in source and the reorder path navigates on a BuildContext captured before an
await, so this cannot merge as written. The subscription opened in initState is never cancelled, and
the row hardcodes its text style ahead of the theming work.

## Area health
- Widget structure | Adequate | A single small row widget with one short build(), though the trailing
  action would read better as its own widget.
- State management | Adequate | Local setState is the right scope for a per-row status, and the value
  is only written from the one listener.
- Performance and rebuilds | Weak | Nothing in build() is expensive, but the missing const on the
  icon and the inline TextStyle stop the subtree short-circuiting a rebuild.
- Lifecycle and disposal | Weak | The subscription taken in initState is never cancelled, and
  dispose() only calls super.
- Accessibility and theming | Weak | The IconButton has no tooltip and the text style is hardcoded
  rather than taken from the theme.
- Security and data handling | Weak | A bearer token is a compile-time constant in the widget.
- Dart idioms | Adequate | Null safety is used properly and the widget takes a super.key.

## Blocking defects
- Bearer token hardcoded in the widget | Line 11 | `authToken` holds what looks like a live secret,
  so it ships inside the app binary and can be read out of it | Move it to a `--dart-define` value
  or a secure store, and rotate the exposed token now.
- BuildContext used after an await | Line 26, `_reorder` | The context is captured before
  `api.reorder` and used afterwards, so popping the route mid-request throws | Guard the resumption:
  `if (!context.mounted) return;` before the `Navigator.of(context)` call.

## Should fix
- Subscription never cancelled | Lines 17 and 21 | `_sub` is opened in initState and dispose() only
  calls super, so the listener outlives the row and calls setState on a dead State | Cancel it:
  `_sub?.cancel();` before `super.dispose()`.

## Polish
- Hardcoded text style | Line 32 | An inline `TextStyle(fontSize: 16)` bypasses the theme, so the row
  will not follow the design system when theming lands | Use
  `Theme.of(context).textTheme.bodyMedium`.
- IconButton with no tooltip | Line 33 | An icon-only button has no accessible name, so a screen
  reader announces nothing | Add `tooltip: 'Reorder'`, from the localizations once they exist.
- Icon and TextStyle could be const | Lines 32-33 | Without const the subtree rebuilds with the
  parent | Write `const Icon(Icons.refresh)` once the style comes from the theme.

## What's solid
- The widget takes `super.key` and its order is a required final field, so it is cheap to place in a
  keyed list.
- The status listener writes one field through setState rather than mutating the order object.

## Next steps
- Add a widget test that pumps the row, emits a status on the feed, disposes it and asserts no
  setState-after-dispose error - it would have caught the leak above.
- Consider moving the status feed to the existing Provider controller so the row stays presentational.
No emoji anywhere, and no zero-width or variation-selector characters — the contract forbids them, so a reply carrying one is a signal that something upstream mangled the text.

The free lane is client-side, and you can have it too

The facts block is produced by one vendored module in the bundle, /dartscan.js, with no network access and no dependencies. It exposes window.DartScan.scan(text) for the structured result (state management guess, widget and build() counts, and every pattern hit with its line number) and window.DartScan.summarize(text) for the exact plain-text block this API takes as facts. The companion /report.js is the other half: window.ClinicReport.parseResult(text) decodes a reply into the review object and returns null when the contract is not met — the same check step 6 rebuilds by hand.

So a pipeline that only wants the mechanical scan does not need this API at all: load those two files in a browser or a JS runtime and call them. The metered endpoint is the judgement half — deciding which hit is real, finding what no pattern can find, and writing the fix.

<!-- In a browser: two plain script tags, no bundler, no network. -->
<script src="/dartscan.js"></script>
<script src="/report.js"></script>

// In Node: both files are plain scripts that assign to `window`, so pointing
// `window` at the global object and requiring them is all it takes. No bundler,
// no network.
const fs = require("fs");
global.window = global;
require("./dartscan.js");
require("./report.js");

const source = fs.readFileSync("lib/features/orders/order_tile.dart", "utf8");

// The same `facts` string the app sends - hand it straight to /estimate and /run.
const facts = window.DartScan.summarize(source);
console.log(facts);

// The structured form, if you would rather gate on it directly:
const scan = window.DartScan.scan(source);
console.log(scan.stack || "no state library detected", scan.lines, "lines,",
            scan.widgets, "widget classes,", scan.at.secrets.length, "credential-shaped hits");

// And the decoder for the reply, contract check included:
const review = window.ClinicReport.parseResult(replyText);
if (!review) throw new Error("reply failed the output contract - retry with retry_note");
console.log(review.verdict, review.findings.blocking.length, "blocking defects");
if (review.verdict === "Rework") process.exitCode = 1;
The scan is pattern-matching, not judgement: it will flag a string that merely looks like a secret, and it will miss a leak that no regular expression can see - it does not parse Dart. Never gate a build on the scan alone — gate it on the verdict, which is the half that actually read the code.