Review PostgreSQL from your own scripts
Send SQL — a schema, a migration, one slow query, or a whole dump of DDL — and
get back one JSON object: an honest verdict, a health check across five areas, findings
ranked by severity each with runnable corrective SQL, the indexes the workload actually
needs, and a complete corrected rewrite of what you pasted. Everything this app does goes
through the SkillSafe App API — plain JSON over HTTPS — so you can wire review
into a migration CI gate, a pull-request bot, or a pre-deploy check that refuses to ship a
varchar(255) money column.
Every code step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#;
pick a language once and the whole page follows.
Basics
Base URL: https://api.skillsafe.ai/v1/app-api, app slug
sql-clinic. Every request sends
Authorization: Bearer <token> and JSON bodies with
Content-Type: application/json. Responses are wrapped in an envelope:
{"data": …} on success, {"error": {"code", "message"}} on failure.
The review itself is produced by the gpt-terra model. Estimates are free;
runs are metered against your credit balance. There is a single run task — one paste
in, one review out, no follow-up calls and no session state to carry.
| Status | Meaning |
|---|---|
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/credits. |
403 | The token isn't allowed to do this (e.g. a guest reviewing a very large schema dump). |
404 | Unknown job or record id. |
5xx | Transient platform error — retry with backoff. |
Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.
Step 0 — A tiny client
Every task below is a single HTTP call, so start with a short helper that adds the auth
header, sends JSON and unwraps the data envelope. The later steps reuse it.
export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN" # see step 1
# every call looks like:
# curl -s "$API/..." -H "Authorization: Bearer $TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": ...} envelope
import json, requests
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # see step 1 — read it from your shell environment in real code
def api(method, path, body=None, **headers):
res = requests.request(method, API + path, json=body,
headers={"Authorization": f"Bearer {TOKEN}", **headers})
payload = res.json()
if not res.ok:
raise RuntimeError(payload.get("error", {}).get("message", res.reason))
return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — read it from your shell environment in real code
async function api(method, path, body, extraHeaders = {}) {
const res = await fetch(API + path, {
method,
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const API = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1
func call(method, path string, body, out any) error {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, API+path, &buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env struct {
Data json.RawMessage `json:"data"`
Error *struct{ Message string `json:"message"` } `json:"error"`
}
json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode >= 400 {
return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
}
if out == nil {
return nil
}
return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class SkillSafe {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
static final HttpClient HTTP = HttpClient.newHttpClient();
static String api(String method, String path, String jsonBody) throws Exception {
var req = HttpRequest.newBuilder(URI.create(API + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, jsonBody == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) throw new RuntimeException(res.body());
return res.body(); // envelope: {"data": …}
}
}
require "net/http"
require "json"
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1
def api(method, path, body = nil)
uri = URI(API + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = body.to_json if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1
function api(string $method, string $path, ?array $body = null): mixed {
global $TOKEN;
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
]);
$payload = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) {
throw new Exception($payload["error"]["message"] ?? "HTTP $status");
}
return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;
static class SkillSafe
{
const string Api = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Http = new();
static SkillSafe() =>
Http.DefaultRequestHeaders.Authorization =
new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1
public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
{
var req = new HttpRequestMessage(method, Api + path);
if (body != null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!res.IsSuccessStatusCode)
throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
return json.GetProperty("data");
}
}
Step 1 — Get a token
A guest token lets you check balances and estimate costs for free. For metered review runs
billed to your own account, use your personal token: open the
token page, sign in with SkillSafe, and press
Copy shell export — it puts export SKILLSAFE_TOKEN="…" on your
clipboard, which every example below reads. Treat the token like a password: it can spend
your credits. For fully headless scripts, POST /guest mints a guest token with
no browser involved.
curl -s -X POST "$API/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"sql-clinic"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "sql-clinic"})["token"]
const { token } = await api("POST", "/guest", { slug: "sql-clinic" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "sql-clinic"}, &guest)
String envelope = api("POST", "/guest", """
{"slug":"sql-clinic"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "sql-clinic" })["token"]
$token = api("POST", "/guest", ["slug" => "sql-clinic"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
new { slug = "sql-clinic" });
var token = guest.GetProperty("token").GetString();
The app stores this browser's token under the localStorage key
skillsafe_app_token:sql-clinic, on the app's own origin. The
token page reads and manages it for you — you never need
to open developer tools.
Step 2 — Check who you are and your balance
Returns subject_type ("user" or "guest"),
subject_id and your credits balance. Check this before reviewing
a large schema dump.
curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
SubjectType string `json:"subject_type"`
Credits int64 `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");
Step 3 — Estimate the cost
Send exactly the input you would send to /run; the response's
hold_credits is the worst-case cost. Nothing is charged and no job is created,
so estimating is free — useful when you are feeding in a whole migration directory
and want a ceiling before spending credits.
| Input field | Type | Notes |
|---|---|---|
sql | string, required | The SQL to review: DDL, a migration, queries, or a mix. Very long pastes may be clipped middle-out, with a [... clipped ...] marker showing where. |
flavor | string | postgres | supabase. On supabase, Row Level Security is a first-class concern: a table without RLS is a finding, and every auth.uid() in a policy must be wrapped as (SELECT auth.uid()) so it is evaluated once per statement rather than once per row. |
workload | string | oltp | analytics | mixed — calibrates indexing and rewrite advice (BRIN and partitioning matter for analytics; lock contention and hot-path scans for OLTP). |
pgversion | string, optional | Your PostgreSQL major version, verbatim, e.g. "16". Nothing newer than it will be recommended. Left out, a recent version is assumed. |
notes | string, optional | Extra context: table sizes, query frequency, what is slow, what the app does. |
prescan_facts | object, optional | What a client-side prescan mechanically detected in the SQL: {"antipatterns": [], "tables": [], "signals": []}. Each entry is {id, label} — keyword-matched anti-pattern hits (ap:varchar-n), CREATE TABLE names found (table:orders) and workload signals (sig:pagination, sig:jsonb, sig:rls). Every id you send comes back in coverage_check. The web UI fills this from its own scan; API callers may omit the field or send the three empty arrays. |
retry_note | string, optional | Only set by the app's automatic reformat retry when a first reply was not valid JSON. Leave it out. |
cat > schema.sql <<'SQL'
CREATE TABLE orders (
id serial PRIMARY KEY,
customer_id int NOT NULL REFERENCES customers(id),
email varchar(255) NOT NULL,
total float NOT NULL,
created_at timestamp DEFAULT now()
);
SELECT * FROM orders WHERE customer_id = $1
ORDER BY created_at DESC LIMIT 20 OFFSET 400;
SQL
jq -n --rawfile s schema.sql \
'{sql: $s, flavor: "postgres", workload: "oltp", pgversion: "16",
notes: "orders is about 40M rows; the listing query is the slowest endpoint we have.",
prescan_facts: {antipatterns: [], tables: [], signals: []}}' > input.json
curl -s -X POST "$API/estimate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d @input.json | jq '.data.hold_credits'
SQL = """CREATE TABLE orders (
id serial PRIMARY KEY,
customer_id int NOT NULL REFERENCES customers(id),
email varchar(255) NOT NULL,
total float NOT NULL,
created_at timestamp DEFAULT now()
);
SELECT * FROM orders WHERE customer_id = $1
ORDER BY created_at DESC LIMIT 20 OFFSET 400;"""
payload = {
"sql": SQL,
"flavor": "postgres",
"workload": "oltp",
"pgversion": "16",
"notes": "orders is about 40M rows; the listing query is the slowest endpoint we have.",
"prescan_facts": {"antipatterns": [], "tables": [], "signals": []},
}
est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const sql = `CREATE TABLE orders (
id serial PRIMARY KEY,
customer_id int NOT NULL REFERENCES customers(id),
email varchar(255) NOT NULL,
total float NOT NULL,
created_at timestamp DEFAULT now()
);
SELECT * FROM orders WHERE customer_id = $1
ORDER BY created_at DESC LIMIT 20 OFFSET 400;`;
const payload = {
sql,
flavor: "postgres",
workload: "oltp",
pgversion: "16",
notes: "orders is about 40M rows; the listing query is the slowest endpoint we have.",
prescan_facts: { antipatterns: [], tables: [], signals: [] },
};
const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
const sql = `CREATE TABLE orders (
id serial PRIMARY KEY,
customer_id int NOT NULL REFERENCES customers(id),
email varchar(255) NOT NULL,
total float NOT NULL,
created_at timestamp DEFAULT now()
);
SELECT * FROM orders WHERE customer_id = $1
ORDER BY created_at DESC LIMIT 20 OFFSET 400;`
payload := map[string]any{
"sql": sql,
"flavor": "postgres",
"workload": "oltp",
"pgversion": "16",
"notes": "orders is about 40M rows; the listing query is the slowest endpoint we have.",
"prescan_facts": map[string]any{
"antipatterns": []any{}, "tables": []any{}, "signals": []any{},
},
}
var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", payload, &est)
String sql = """
CREATE TABLE orders (
id serial PRIMARY KEY,
customer_id int NOT NULL REFERENCES customers(id),
email varchar(255) NOT NULL,
total float NOT NULL,
created_at timestamp DEFAULT now()
);
SELECT * FROM orders WHERE customer_id = $1
ORDER BY created_at DESC LIMIT 20 OFFSET 400;""";
String jsonPayload = """
{"sql": %s, "flavor": "postgres", "workload": "oltp", "pgversion": "16",
"notes": "orders is about 40M rows; the listing query is the slowest endpoint we have.",
"prescan_facts": {"antipatterns": [], "tables": [], "signals": []}}
""".formatted(toJsonString(sql));
String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
SQL_TEXT = <<~SQL
CREATE TABLE orders (
id serial PRIMARY KEY,
customer_id int NOT NULL REFERENCES customers(id),
email varchar(255) NOT NULL,
total float NOT NULL,
created_at timestamp DEFAULT now()
);
SELECT * FROM orders WHERE customer_id = $1
ORDER BY created_at DESC LIMIT 20 OFFSET 400;
SQL
payload = { sql: SQL_TEXT, flavor: "postgres", workload: "oltp", pgversion: "16",
notes: "orders is about 40M rows; the listing query is the slowest endpoint we have.",
prescan_facts: { antipatterns: [], tables: [], signals: [] } }
est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$sql = <<<'SQL'
CREATE TABLE orders (
id serial PRIMARY KEY,
customer_id int NOT NULL REFERENCES customers(id),
email varchar(255) NOT NULL,
total float NOT NULL,
created_at timestamp DEFAULT now()
);
SELECT * FROM orders WHERE customer_id = $1
ORDER BY created_at DESC LIMIT 20 OFFSET 400;
SQL;
$payload = [
"sql" => $sql,
"flavor" => "postgres",
"workload" => "oltp",
"pgversion" => "16",
"notes" => "orders is about 40M rows; the listing query is the slowest endpoint we have.",
"prescan_facts" => ["antipatterns" => [], "tables" => [], "signals" => []],
];
$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var sql = """
CREATE TABLE orders (
id serial PRIMARY KEY,
customer_id int NOT NULL REFERENCES customers(id),
email varchar(255) NOT NULL,
total float NOT NULL,
created_at timestamp DEFAULT now()
);
SELECT * FROM orders WHERE customer_id = $1
ORDER BY created_at DESC LIMIT 20 OFFSET 400;
""";
var payload = new {
sql,
flavor = "postgres",
workload = "oltp",
pgversion = "16",
notes = "orders is about 40M rows; the listing query is the slowest endpoint we have.",
prescan_facts = new {
antipatterns = Array.Empty<object>(), tables = Array.Empty<object>(),
signals = Array.Empty<object>(),
},
};
var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits");
prescan_facts is how you make the review answer for things you already know
about. Send {"antipatterns": [{"id": "ap:varchar-n", "label": "varchar(n) column"}],
"tables": [{"id": "table:orders", "label": "orders"}], "signals": [{"id": "sig:pagination",
"label": "OFFSET pagination"}]} and every one of those ids comes back in
coverage_check — addressed, or explained away as a false positive.
Nothing you flag is silently dropped.
Step 4 — Run the review and wait for the result
/run takes the same input as /estimate, places a credit hold and
returns a job_id. Poll /jobs/{job_id} every 1–2 seconds
until status is succeeded or failed (a run typically
takes 30–90 s, since the corrected rewrite is written out in full). Always send an
Idempotency-Key header so a network retry can't start a second,
double-charged run. The review is in output — usually nested as
output.output, and as a JSON string, so parse defensively. The samples
below print the review name and verdict, the five health areas and the findings, then write
rewrite.code to improved.sql.
JOB_ID=$(curl -s -X POST "$API/run" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: review-$(date +%s)" \
-d @input.json | jq -r '.data.job_id')
while :; do
JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
STATUS=$(echo "$JOB" | jq -r '.data.status')
[ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
sleep 2
done
# unwrap the review once, then read it
echo "$JOB" | jq -r '.data.output.output' > review.json
jq -r '
"\(.review_name): \(.verdict)",
"",
"HEALTH",
(.health[] | " [\(.status)] \(.area) - \(.note)"),
"",
"FINDINGS",
(.findings[] | " (\(.severity)) \(.category): \(.title)"),
"",
"MISSING INDEXES",
(.indexes[] | " \(.statement) -- \(.reason)")' review.json
# and drop the corrected SQL straight into the repo
jq -r '.rewrite.code' review.json > "$(jq -r '.rewrite.filename' review.json)" # improved.sql
import time
job_id = api("POST", "/run", payload,
**{"Idempotency-Key": "review-001"})["job_id"]
while True:
job = api("GET", f"/jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(1.5)
if job["status"] == "failed":
raise RuntimeError(job.get("error", "run failed"))
raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
raw = raw["output"]
review = json.loads(raw) if isinstance(raw, str) else raw
print(f'{review["review_name"]}: {review["verdict"]}')
for area in review["health"]:
print(f' [{area["status"]:>4}] {area["area"]:<18} {area["note"]}')
for f in review["findings"]:
print(f' ({f["severity"]}) {f["category"]}: {f["title"]}')
if f["fix_sql"]:
print(f' {f["fix_sql"]}')
for ix in review["indexes"]:
print(f' {ix["statement"]} -- {ix["reason"]}')
for c in review["coverage_check"]:
print(f' {c["id"]}: {"ok" if c["addressed"] else "SET ASIDE"} - {c["note"]}')
with open(review["rewrite"]["filename"], "w", encoding="utf-8") as fh: # improved.sql
fh.write(review["rewrite"]["code"])
import { writeFileSync } from "node:fs";
const { job_id } = await api("POST", "/run", payload,
{ "Idempotency-Key": crypto.randomUUID() });
let job;
do {
await new Promise((r) => setTimeout(r, 1500));
job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");
if (job.status === "failed") throw new Error(job.error ?? "run failed");
const raw = job.output?.output ?? job.output;
const review = typeof raw === "string" ? JSON.parse(raw) : raw;
console.log(`${review.review_name}: ${review.verdict}`);
for (const area of review.health) {
console.log(` [${area.status}] ${area.area}: ${area.note}`);
}
for (const f of review.findings) {
console.log(` (${f.severity}) ${f.category}: ${f.title}`);
if (f.fix_sql) console.log(` ${f.fix_sql}`);
}
for (const ix of review.indexes) console.log(` ${ix.statement} -- ${ix.reason}`);
for (const c of review.coverage_check) {
console.log(` ${c.id}: ${c.addressed ? "ok" : "SET ASIDE"} - ${c.note}`);
}
writeFileSync(review.rewrite.filename, review.rewrite.code); // improved.sql
var started struct{ JobID string `json:"job_id"` }
if err := call("POST", "/run", payload, &started); err != nil {
log.Fatal(err)
}
var job struct {
Status string `json:"status"`
Error string `json:"error"`
Output json.RawMessage `json:"output"`
}
for {
if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
log.Fatal(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
time.Sleep(1500 * time.Millisecond)
}
// job.Output is {"output": "<json string>"} — unwrap, unquote, then unmarshal:
type Review struct {
ReviewName string `json:"review_name"`
Verdict string `json:"verdict"`
Health []struct {
Area, Status, Note string
} `json:"health"`
Findings []struct {
Severity, Category, Title, Detail string
FixSQL string `json:"fix_sql"`
} `json:"findings"`
Indexes []struct {
Statement, Reason string
} `json:"indexes"`
Rewrite struct {
Filename, Code string
} `json:"rewrite"`
}
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
var review Review
json.Unmarshal([]byte(wrapper.Output), &review)
fmt.Printf("%s: %s\n", review.ReviewName, review.Verdict)
for _, a := range review.Health {
fmt.Printf(" [%s] %s: %s\n", a.Status, a.Area, a.Note)
}
for _, f := range review.Findings {
fmt.Printf(" (%s) %s: %s\n", f.Severity, f.Category, f.Title)
}
for _, ix := range review.Indexes {
fmt.Printf(" %s -- %s\n", ix.Statement, ix.Reason)
}
os.WriteFile(review.Rewrite.Filename, []byte(review.Rewrite.Code), 0o644) // improved.sql
String envelope = api("POST", "/run", jsonPayload);
String jobId = /* data.job_id via your JSON library */;
while (true) {
String job = api("GET", "/jobs/" + jobId, null);
String status = /* data.status */;
if (status.equals("succeeded") || status.equals("failed")) break;
Thread.sleep(1500);
}
// The review is at data.output.output as a JSON string — parse it again, then read
// review_name, verdict, overview, health[] (five areas with area/status/note),
// findings[] (severity/category/title/detail/fix_sql), indexes[] (statement/reason),
// coverage_check[] (id/addressed/note), rewrite{filename, code}, next_steps[] and summary.
// Finally write the corrected SQL to disk:
// Files.writeString(Path.of(rewriteFilename), rewriteCode); // improved.sql
started = api("POST", "/run", payload)
job = nil
loop do
job = api("GET", "/jobs/#{started["job_id"]}")
break if %w[succeeded failed].include?(job["status"])
sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"
raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
review = raw.is_a?(String) ? JSON.parse(raw) : raw
puts "#{review["review_name"]}: #{review["verdict"]}"
review["health"].each { |a| puts " [#{a["status"]}] #{a["area"]}: #{a["note"]}" }
review["findings"].each do |f|
puts " (#{f["severity"]}) #{f["category"]}: #{f["title"]}"
puts " #{f["fix_sql"]}" unless f["fix_sql"].to_s.empty?
end
review["indexes"].each { |ix| puts " #{ix["statement"]} -- #{ix["reason"]}" }
review["coverage_check"].each { |c| puts " #{c["id"]}: #{c["addressed"] ? "ok" : "SET ASIDE"}" }
File.write(review["rewrite"]["filename"], review["rewrite"]["code"]) # improved.sql
$started = api("POST", "/run", $payload);
do {
sleep(2);
$job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));
if ($job["status"] === "failed") {
throw new Exception($job["error"] ?? "run failed");
}
$raw = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
$review = is_string($raw) ? json_decode($raw, true) : $raw;
echo "{$review['review_name']}: {$review['verdict']}\n";
foreach ($review["health"] as $a) {
echo " [{$a['status']}] {$a['area']}: {$a['note']}\n";
}
foreach ($review["findings"] as $f) {
echo " ({$f['severity']}) {$f['category']}: {$f['title']}\n";
if ($f["fix_sql"] !== "") { echo " {$f['fix_sql']}\n"; }
}
foreach ($review["indexes"] as $ix) {
echo " {$ix['statement']} -- {$ix['reason']}\n";
}
foreach ($review["coverage_check"] as $c) {
echo " {$c['id']}: " . ($c["addressed"] ? "ok" : "SET ASIDE") . "\n";
}
file_put_contents($review["rewrite"]["filename"], $review["rewrite"]["code"]); // improved.sql
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload);
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status is "succeeded" or "failed") break;
await Task.Delay(1500);
}
var rawText = job.GetProperty("output").GetProperty("output").GetString();
using var doc = JsonDocument.Parse(rawText!);
var review = doc.RootElement;
Console.WriteLine($"{review.GetProperty("review_name")}: {review.GetProperty("verdict")}");
foreach (var a in review.GetProperty("health").EnumerateArray())
{
Console.WriteLine($" [{a.GetProperty("status")}] {a.GetProperty("area")}: {a.GetProperty("note")}");
}
foreach (var f in review.GetProperty("findings").EnumerateArray())
{
Console.WriteLine($" ({f.GetProperty("severity")}) {f.GetProperty("category")}: " +
$"{f.GetProperty("title")}");
}
foreach (var ix in review.GetProperty("indexes").EnumerateArray())
{
Console.WriteLine($" {ix.GetProperty("statement")} -- {ix.GetProperty("reason")}");
}
var rewrite = review.GetProperty("rewrite");
await File.WriteAllTextAsync(rewrite.GetProperty("filename").GetString()!, // improved.sql
rewrite.GetProperty("code").GetString()!);
The model is asked for one JSON object and nothing else, but a stray code fence or preamble
is always possible. Strip a leading ```json fence, take the text between the
first { and the last }, and only then parse — that is what
the app does before it falls back to a retry_note reformat run.
The review object — output schema
One JSON object, always the same shape. Every array is present (findings and
indexes are empty only if genuinely nothing applies); health
always has exactly the five areas, and rewrite.code is never empty. If the
paste was too thin to review responsibly, you still get this object: what is there gets
reviewed, the verdict says the paste is thin, and what you would need to
show lands in next_steps. If the paste is not SQL at all, you still get the
object — one high-severity finding explaining what arrived, every health area at
risk, and a rewrite.code comment block saying what to paste
instead.
| Field | Type | Meaning |
|---|---|---|
review_name | string | A short name for the review, taken from the SQL's own table or domain naming. |
verdict | string | One or two sentences: the overall state and the single most important fix. |
overview | string | One or two paragraphs: what this schema or query does, and the pattern behind what was found. |
health | array of 5 | {area, status, note} — the five areas listed below, each exactly once. status is good (nothing material), risk (works, with caveats) or bad (a high-severity finding lives here). Each note references something concrete in the pasted SQL; an area with no evidence in the paste is risk, never good. |
findings | array | {severity, category, title, detail, fix_sql}. severity is high | medium | low; category is schema-design, data-types, indexing, query-performance, security or operations. detail quotes the table, column or clause it concerns; fix_sql is runnable corrective SQL against your own object names, or an empty string when the finding is a trade-off rather than a mechanical fix. |
indexes | array | {statement, reason} — only indexes that are missing, each a complete CREATE INDEX statement, with the query shape or foreign key it serves in reason. |
coverage_check | array | {id, addressed, note} — one entry per prescan_facts item you sent (ap:varchar-n, table:orders, …), saying where the review covers it or why it was set aside (a keyword hit can be a false positive; the note says so). Nothing you flagged is silently dropped. |
rewrite | object | {filename, code} — filename is improved.sql, and code is your own SQL corrected: same objects, same intent, findings fixed, your naming and comments preserved. It is a complete runnable replacement, ordered so it executes: types and tables first, then indexes, then policies, then queries. |
next_steps | string[] | Ordered and concrete: run EXPLAIN (ANALYZE, BUFFERS) on the listed query, backfill in batches, enable pg_stat_statements, and so on. |
summary | string | 3–5 sentences a tech lead could paste into a code review. |
The five health areas, in order, spelled exactly like this:
| area | What its note covers |
|---|---|
Schema design | Keys, data types, constraints, normalisation — bigint identity over serial, text over varchar(n), timestamptz over timestamp, numeric over float for money. |
Indexing | Unindexed foreign keys, column order in composite indexes, covering and partial indexes, GIN for jsonb and full-text, BRIN for append-only time series. |
Query performance | Scan shapes on hot paths, OFFSET versus keyset pagination, SELECT *, sargability, joins. |
Security & RLS | Row Level Security coverage, policy correctness, and — on supabase — auth.uid() wrapped as (SELECT auth.uid()) so it runs once per statement. |
Operations | Migration safety and locking, bloat and vacuum, queue patterns (FOR UPDATE SKIP LOCKED), partitioning and observability. |
A small, realistic result for the orders paste above, trimmed for length:
{
"review_name": "orders schema and customer listing query",
"verdict": "The table is workable but three column types are wrong and the listing query
will get slower with every page; fix orders.total before anything else.",
"overview": "A single orders table with a customer foreign key and one paginated listing
query. The problems are the classic set: MySQL-era types, no index behind
the foreign key, and OFFSET pagination on a 40M-row table.",
"health": [
{ "area": "Schema design", "status": "bad",
"note": "orders.total is float and orders.created_at is timestamp without time zone." },
{ "area": "Indexing", "status": "bad",
"note": "orders.customer_id has a REFERENCES clause but no index." },
{ "area": "Query performance", "status": "bad",
"note": "OFFSET 400 on a 40M-row table scans and discards 400 rows per page." },
{ "area": "Security & RLS", "status": "risk",
"note": "flavor is postgres and no policies were pasted, so RLS is unobservable here." },
{ "area": "Operations", "status": "risk",
"note": "No migration wrapper pasted; the type changes below rewrite the table." }
],
"findings": [
{ "severity": "high", "category": "data-types",
"title": "orders.total is float",
"detail": "'total float NOT NULL' stores money in binary floating point; sums drift.",
"fix_sql": "ALTER TABLE orders ALTER COLUMN total TYPE numeric(12,2);" },
{ "severity": "high", "category": "indexing",
"title": "orders.customer_id has no index",
"detail": "Postgres does not index the referencing side of a foreign key; the listing
query filters on customer_id and every DELETE on customers scans orders.",
"fix_sql": "CREATE INDEX CONCURRENTLY orders_customer_id_created_at_idx
ON orders (customer_id, created_at DESC);" },
{ "severity": "medium", "category": "data-types",
"title": "orders.created_at is timestamp, not timestamptz",
"detail": "'created_at timestamp DEFAULT now()' drops the offset now() carries.",
"fix_sql": "ALTER TABLE orders ALTER COLUMN created_at TYPE timestamptz
USING created_at AT TIME ZONE 'UTC';" }
],
"indexes": [
{ "statement": "CREATE INDEX CONCURRENTLY orders_customer_id_created_at_idx
ON orders (customer_id, created_at DESC);",
"reason": "Serves the customer_id equality filter and the created_at DESC ordering,
and covers the foreign key." }
],
"coverage_check": [
{ "id": "ap:varchar-n", "addressed": true,
"note": "orders.email varchar(255) — changed to text with a length CHECK." },
{ "id": "sig:pagination", "addressed": true,
"note": "OFFSET replaced with keyset pagination on (created_at, id)." }
],
"rewrite": { "filename": "improved.sql", "code": "CREATE TABLE orders (\n id bigint …" },
"next_steps": [
"Run EXPLAIN (ANALYZE, BUFFERS) on the listing query before and after the new index.",
"Apply the type changes in a maintenance window — each ALTER rewrites the table.",
"Switch the API to keyset pagination and drop the page-number parameter."
],
"summary": "Three type corrections, one missing index, and a pagination rewrite. …"
}
The rewrite is a starting point, not a migration plan: it is written to be runnable and self-consistent with the findings, but it is AI-generated and several of these statements rewrite the whole table. Review it, wrap it in your own migration tooling, and run it against a restored copy before it goes anywhere near production.
Step 5 — Stream the review as it is written
/run-stream takes exactly the same body as /run but answers with
server-sent events, so you can show progress instead of a spinner — useful here
because the corrected rewrite makes for a long reply. This app's own progress panel is this
endpoint. Events are separated by a blank line; each has an event: line and a
data: line carrying JSON.
| Event | Payload | Meaning |
|---|---|---|
job | {job_id, status} | Sent once, when the job is accepted — show "starting". |
delta | {text} | A chunk of the reply, in order. Append it; the accumulated length is your only progress signal (the total is not known in advance). |
done | {job_id, status, charged_credits, output} | The final, authoritative result — read the review from output.output rather than trusting concatenated deltas, and the settled price from charged_credits. |
error | {code, message} | Replaces done when the run fails. |
# -N disables buffering so events print as they arrive
curl -N -s -X POST "$API/run-stream" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: review-$(date +%s)" \
-d @input.json
# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"{\"review_name\":\"orders"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":612,"output":{"output":"{...}"}}
import json, requests
result = None
with requests.post(
API + "/run-stream",
headers={"Authorization": f"Bearer {TOKEN}",
"Idempotency-Key": "review-001"},
json=payload,
stream=True,
) as r:
r.raise_for_status()
event = None
for line in r.iter_lines(decode_unicode=True):
if not line:
continue
if line.startswith("event:"):
event = line[len("event:"):].strip()
elif line.startswith("data:"):
data = json.loads(line[len("data:"):].strip())
if event == "delta":
print(".", end="", flush=True) # live progress
elif event == "done":
result = data
elif event == "error":
raise RuntimeError(data.get("message", "run failed"))
review = json.loads(result["output"]["output"]) # authoritative
print("charged:", result["charged_credits"], "-", review["review_name"])
for area in review["health"]:
print(f' [{area["status"]}] {area["area"]}')
open(review["rewrite"]["filename"], "w", encoding="utf-8").write(review["rewrite"]["code"])
const res = await fetch(API + "/run-stream", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify(payload),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", done = null;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
const frames = buf.split("\n\n");
buf = frames.pop();
for (const frame of frames) {
const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
const body = /^data:\s*(.+)$/m.exec(frame)?.[1];
if (!name || !body) continue;
const data = JSON.parse(body);
if (name === "delta") process.stdout.write("."); // live progress
if (name === "done") done = data;
if (name === "error") throw new Error(data.message ?? "run failed");
}
}
const review = JSON.parse(done.output.output);
console.log(`\n${done.charged_credits} credits - ${review.review_name}`);
for (const area of review.health) console.log(` [${area.status}] ${area.area}`);
writeFileSync(review.rewrite.filename, review.rewrite.code); // improved.sql
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "review-001")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
var event string
var final map[string]any
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
var data map[string]any
json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
switch event {
case "delta":
fmt.Print(".") // live progress
case "done":
final = data
case "error":
log.Fatal(data["message"])
}
}
}
// final["output"].(map[string]any)["output"].(string) is the review JSON —
// unmarshal it into the Review struct from step 4, then write review.Rewrite.Code to disk.
// Java 17+ — read the stream line by line instead of buffering the body.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "review-001")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
if (line.startsWith("event:")) {
event = line.substring(6).trim();
} else if (line.startsWith("data:")) {
String data = line.substring(5).trim();
if ("delta".equals(event)) System.out.print("."); // live progress
else if ("done".equals(event)) done = data;
else if ("error".equals(event)) throw new RuntimeException(data);
}
}
// parse `done`, then parse data.output.output again — it is a JSON string holding
// review_name, health[], findings[], indexes[], rewrite{filename, code} and the rest.
require "net/http"
require "json"
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "review-001"
req.body = payload.to_json
event = nil
done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.strip
if line.start_with?("event:")
event = line.delete_prefix("event:").strip
elsif line.start_with?("data:")
data = JSON.parse(line.delete_prefix("data:").strip)
case event
when "delta" then print "." # live progress
when "done" then done = data
when "error" then raise (data["message"] || "run failed")
end
end
end
end
end
end
review = JSON.parse(done["output"]["output"])
puts "\n#{done["charged_credits"]} credits - #{review["review_name"]}"
review["health"].each { |a| puts " [#{a["status"]}] #{a["area"]}" }
File.write(review["rewrite"]["filename"], review["rewrite"]["code"]) # improved.sql
$event = null;
$done = null;
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
"Idempotency-Key: review-001",
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done) {
foreach (explode("\n", $chunk) as $line) {
$line = trim($line);
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:")) {
$data = json_decode(trim(substr($line, 5)), true);
if ($event === "delta") { echo "."; } // live progress
elseif ($event === "done") { $done = $data; }
elseif ($event === "error") { throw new Exception($data["message"] ?? "run failed"); }
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$review = json_decode($done["output"]["output"], true);
echo "\n{$done['charged_credits']} credits - {$review['review_name']}\n";
foreach ($review["health"] as $a) { echo " [{$a['status']}] {$a['area']}\n"; }
file_put_contents($review["rewrite"]["filename"], $review["rewrite"]["code"]); // improved.sql
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "review-001");
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:"))
{
var data = line[5..].Trim();
if (evt == "delta") Console.Write("."); // live progress
else if (evt == "done") done = data;
else if (evt == "error") throw new Exception(data);
}
}
using var final = JsonDocument.Parse(done!);
var text = final.RootElement.GetProperty("output").GetProperty("output").GetString();
using var reviewDoc = JsonDocument.Parse(text!);
var review = reviewDoc.RootElement;
Console.WriteLine(review.GetProperty("review_name"));
foreach (var a in review.GetProperty("health").EnumerateArray())
Console.WriteLine($" [{a.GetProperty("status")}] {a.GetProperty("area")}");
var rewrite = review.GetProperty("rewrite");
await File.WriteAllTextAsync(rewrite.GetProperty("filename").GetString()!, // improved.sql
rewrite.GetProperty("code").GetString()!);
In a browser, the native EventSource only speaks GET, and this endpoint is a
POST — read the fetch response body incrementally, as the JavaScript
sample above does. On an idempotent replay the server may answer with a plain JSON
envelope instead of an event stream; check the Content-Type before you start
parsing frames.