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.
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
| HTTP | code | What to do |
|---|---|---|
400 | validation_error | The 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. |
401 | unauthorized | No bearer token, or an expired guest token. Mint a new one with POST /guest, or take a personal one from /tokens.html. |
402 | payment_required | The 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. |
404 | not_found | Wrong 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. |
409 | conflict | The 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. |
429 | rate_limited | Back off and retry with the same idempotency key; do not tight-loop. |
500 – 599 | internal | Transient platform or upstream failure. Retry with the same idempotency key so a run that actually started is not billed twice. |
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"]
};
}
/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.
| Field | Type | Notes |
|---|---|---|
code | string, required | The 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. |
notes | string, optional, max 6,000 chars | What 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. |
focus | string, required | Exactly 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. |
facts | string, optional | Plain 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_note | string, optional | Sent 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)."
}
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:
- Line 1 is
VERDICT:followed by exactly one ofShip it,Needs fixes,Rework,Not reviewable. - Line 2 is
STACK:followed by what it reviewed the code as (“Riverpod screen widget”, “flutter_bloc with a repository”, “plain setState StatefulWidget”, “pure Dart, no Flutter”), or exactlyNot statedwhen the input is not identifiable Dart or Flutter. - Line 3 is
CONFIDENCE:followed by a bare integer 0–100. No percent sign, no range, no words. It is confidence that the review is complete and correct for what was pasted: high for a self-contained widget, low for a fragment whose providers and models are invisible. - Then
SUMMARY:and 2–4 sentences. It may wrap over several lines and ends at the first blank line. - Then exactly six
##sections, in this order:## Area health,## Blocking defects,## Should fix,## Polish,## What's solid,## Next steps. All six appear, spelled exactly like that; a missing one is a contract failure, not an empty section. ## Area healthholds exactly seven bullets, one per area, in this order:Widget structure,State management,Performance and rebuilds,Lifecycle and disposal,Accessibility and theming,Security and data handling,Dart idioms. Each carries three|-separated fields: the area name exactly as listed, a rating ofStrong,Adequate,WeakorNot covered, and one sentence of justification. This section never says- None.; an area the paste does not exercise is ratedNot covered.- Bullets under the three defect sections carry exactly four
|-separated fields: the defect in a few words, where it is (Line 42,Lines 30-41,build() of _OrderTileState, orGeneral), the problem with its concrete impact, and the fix to apply. No pipes inside a field; fix code is inline in backticks, never a fenced block.What's solidandNext stepsare plain bullets. - A defect or list section with nothing to report contains the single bullet
- None.— decode that to an empty list, not to a one-item list holding the word.
Cross-rules the verdict must satisfy
| Verdict | What must hold |
|---|---|
Ship it | Blocking defects and Should fix are both - None. Polish items and next steps may still be present. |
Needs fixes | Should-fix defects exist that a competent author can clear before merging. |
Rework | Blocking defects holds at least one defect. |
Not reviewable | All 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.
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;