.NET Clinic — API

Paste the C#, get the best-practices review.

API tokens Open the app

Run the C# best-practices reviewer from your own tools

Send C# source — a controller, a service, a worker, a library type, a test class — and get back one JSON object: which shape the code belongs to and why, a ship-it / tidy-first / rework verdict, the findings with a severity, a rule group, a location, a verbatim evidence line from your own source, the consequence in this code, the fix and a before/after excerpt, an adjudication of every deterministic finding your own scanner sent in, the namespace and project layout read, and the tests worth having. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can run it across a solution's projects in bulk, or post its findings onto a pull request. Be careful about making it a blocking gate: every call bills credits and takes tens of seconds, and the verdict is a model's judgement rather than a deterministic check, so the same code can come back tidy-first one day and rework the next. For something that must pass or fail reproducibly on every commit, use Roslyn analyzers, StyleCop, the .NET analyzers built into the SDK or SonarAnalyzer.CSharp; use this where a human reviewer would otherwise have to read the diff. 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 dotnet-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 is produced by the gpt-terra model at a 10% publisher markup. Estimates are free; runs are metered against your credit balance. There is a single run task — source in, one review out — plus a reviews collection holding your saved runs.

POST /guest GET /me POST /estimate POST /run GET /jobs/{id} POST /run-stream POST /collections/reviews/query
StatusMeaning
400Malformed body — usually a where filter written as a bare value instead of an operator object.
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/billing.
403The token isn't allowed to do this (e.g. a guest submitting a very large paste).
404Unknown job or record id.
429Rate limited — back off and retry.
5xxTransient platform error — retry with backoff, reusing the same idempotency key.

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;

class Api {
    static final String BASE = "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 call(String method, String path, String jsonBody) throws Exception {
        var body = jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody);
        var req = HttpRequest.newBuilder(URI.create(BASE + path))
                .header("Authorization", "Bearer " + TOKEN)
                .header("Content-Type", "application/json")
                .method(method, body)
                .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body());
        return res.body();   // {"data": ...}
    }
}
require "json"
require "net/http"
require "uri"

API   = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN")   # see step 1

def api(method, path, body = nil, extra = {})
  uri = URI(API + path)
  klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post,
            "DELETE" => Net::HTTP::Delete }.fetch(method)
  req = klass.new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"]  = "application/json"
  extra.each { |k, v| req[k] = v }
  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.dig("error", "message").to_s 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, array $extra = []): array {
    global $TOKEN;
    $headers = ["Authorization: Bearer $TOKEN", "Content-Type: application/json"];
    foreach ($extra as $k => $v) { $headers[] = "$k: $v"; }
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_HTTPHEADER     => $headers,
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    $raw    = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    $payload = json_decode($raw, true);
    if ($status >= 400) { throw new RuntimeException($payload["error"]["message"] ?? "request failed"); }
    return $payload["data"];
}
// .NET 6+
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

static class Api {
    const string Base = "https://api.skillsafe.ai/v1/app-api";
    static readonly string Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")!; // step 1
    static readonly HttpClient Http = new();

    public static async Task<JsonElement> Call(HttpMethod method, string path, object? body = null,
                                               (string, string)? extraHeader = null) {
        var req = new HttpRequestMessage(method, Base + path);
        req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
        if (extraHeader is var (hk, hv) && hk is not null) req.Headers.Add(hk, hv);
        if (body is not null)
            req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
        var res = await Http.SendAsync(req);
        var json = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
        if (!res.IsSuccessStatusCode)
            throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

Two kinds. A guest token needs no account and is enough for /me and the free /estimate. A personal token bills metered runs to your own balance — get one from the token page, which shows the token this browser already holds, lets you sign in for a personal one, and copies a ready-made export SKILLSAFE_TOKEN="…" line. You never need the DevTools console.

# A scripted guest token — no browser, no account. Good for /me and /estimate.
# The slug goes in the JSON body, not in a header.
curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"dotnet-clinic"}' | jq -r .data.token

# For a personal token (metered runs bill your account), open
#   https://dotnet-clinic.skillsafe.ai/tokens.html
# sign in, and press "Copy shell export".
# A scripted guest token — no browser, no account. Good for /me and /estimate.
# The slug goes in the JSON body, not in a header.
guest = requests.post(API + "/guest", json={"slug": "dotnet-clinic"}).json()["data"]
TOKEN = guest["token"]          # aut_...
guest_id = guest["guest_id"]    # gst_... — keep it if you later migrate the wallet on sign-in

# For a personal token (metered runs bill your account), open
#   https://dotnet-clinic.skillsafe.ai/tokens.html
# sign in, and press "Copy shell export".
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header.
const guest = await (await fetch(API + "/guest", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ slug: "dotnet-clinic" }),
})).json();
const token = guest.data.token;

// For a personal token (metered runs bill your account), open
//   https://dotnet-clinic.skillsafe.ai/tokens.html
// sign in, and press "Copy shell export".
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header.
guestBody, _ := json.Marshal(map[string]string{"slug": "dotnet-clinic"})
req, _ := http.NewRequest("POST", API+"/guest", bytes.NewReader(guestBody))
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()

var env struct {
	Data struct {
		Token string `json:"token"`
	} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
token = env.Data.Token

// For a personal token, open https://dotnet-clinic.skillsafe.ai/tokens.html
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header.
var req = HttpRequest.newBuilder(URI.create(Api.BASE + "/guest"))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"dotnet-clinic\"}"))
        .build();
var res = Api.HTTP.send(req, HttpResponse.BodyHandlers.ofString());
// res.body() is {"data":{"token":"aut_...","guest_id":"gst_...","expires_at":"..."}}
// — read data.token with your JSON library.

// For a personal token, open https://dotnet-clinic.skillsafe.ai/tokens.html
# A scripted guest token — no browser, no account. Good for /me and /estimate.
# The slug goes in the JSON body, not in a header.
uri = URI(API + "/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.dump({ "slug" => "dotnet-clinic" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
token = JSON.parse(res.body).dig("data", "token")

# For a personal token, open https://dotnet-clinic.skillsafe.ai/tokens.html
<?php
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header.
$ch = curl_init(API . "/guest");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => ["Content-Type: application/json"],
    CURLOPT_POSTFIELDS     => json_encode(["slug" => "dotnet-clinic"]),
]);
$guest  = json_decode(curl_exec($ch), true);
curl_close($ch);
$TOKEN = $guest["data"]["token"];

// For a personal token, open https://dotnet-clinic.skillsafe.ai/tokens.html
// A scripted guest token — no browser, no account. Good for /me and /estimate.
// The slug goes in the JSON body, not in a header.
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest") {
    Content = new StringContent("{\"slug\":\"dotnet-clinic\"}", Encoding.UTF8, "application/json"),
};
var guestRes = await new HttpClient().SendAsync(guestReq);
var guest = JsonDocument.Parse(await guestRes.Content.ReadAsStringAsync()).RootElement;
var token = guest.GetProperty("data").GetProperty("token").GetString();

// For a personal token, open https://dotnet-clinic.skillsafe.ai/tokens.html

Treat the token like a password: anyone holding it can spend its credits through this app. Keep it in your shell environment rather than in source control.

Step 2 — Check the session and the balance

GET /me is free and tells you whether the token is a guest or a real user, and how many credits it can spend. The app calls this before enabling its run button, and so should you — a 402 after submitting is avoidable.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq .data
# { "subject_type": "user", "subject_id": "...", "credits": 184220 }
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"`
}
if err := call("GET", "/me", nil, &me); err != nil {
	panic(err)
}
fmt.Println(me.SubjectType, me.Credits)
String me = Api.call("GET", "/me", null);
System.out.println(me);   // {"data":{"subject_type":"user","credits":184220}}
me = api("GET", "/me")
puts me["subject_type"], me["credits"]
<?php
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"], "\n";
var me = await Api.Call(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");

Step 3 — Estimate (free, no job created)

POST /estimate takes the exact body you would send to /run and returns the model binding and the price envelope without creating a job or charging anything. hold_credits is a worst-case reservation, priced against the full output cap; the settled charged_credits is usually much lower. If the balance sits between min_credits and hold_credits, the run still executes with a reduced cap and comes back "truncated": true.

Input fields

FieldTypeMeaning
sourcestringRequired. The C# source. Several files are allowed, separated by a line reading // File: Name.cs. The app clips anything over 28,000 characters on whole-line boundaries, dropping the middle and keeping the head and the tail, with a // ---- N lines omitted ---- marker where the cut happened.
shapestringaspnet, console, library or tests — the shape to review against. Several conventions invert between them — Console.WriteLine is the output of a console app and a defect in a service — so this is not cosmetic.
shape_evidencestringWhy that shape: the weighted markers the client scan matched, or that the user chose it explicitly.
focusstringall, or one rule group: async, exceptions, di, disposal, logging, security, naming, testing.
contextstringWhat the code does, what the caller already knows, what they are worried about. This is what turns a rule violation into a judgement — a documented boundary handler gets set aside rather than repeated.
prescanobjectStructural facts the client measured: files, types, methods, code_lines, aspnet_score, console_score, tests_score, source_clipped.
prescan_findingsarray{"id": "A2-1", "rule": "A2", "severity": "high", "line": 31, "title": "…", "evidence": "…"} entries from your own deterministic scan. rule is one of the 32 rule codes — N1 N2 N3 N4 N5 D1 A1 A2 A3 A4 E1 E2 E3 NU1 DI1 DI2 DS1 DS2 L1 L2 L3 SEC1 SEC2 CF1 SM1 SM2 SM3 SM4 SM5 SM6 T1 T2. The review must return exactly one prescan_check entry per id. Send [] if you have no scanner of your own.
retry_notestringOptional. Sent only on the app's one reformat retry.
curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"source":"public class orderService { public Order Get(int id) { return _repo.FindAsync(id).Result; } }","shape":"aspnet","focus":"all","prescan_findings":[]}' | jq .data

# { "model": "gpt-5.6-terra", "model_alias": "gpt-terra", "markup_bps": 1000,
#   "hold_credits": 3140, "min_credits": 420, "sponsor_enabled": false }
SOURCE = open("OrderService.cs").read()

payload = {
    "source": SOURCE,
    "shape": "aspnet",
    "shape_evidence": "[ApiController], app.MapPost and Microsoft.AspNetCore usings.",
    "focus": "all",
    "context": "Orders endpoint, ~400 req/s. Worried about the error handling.",
    "prescan": {"files": 1, "types": 1, "methods": 4, "code_lines": 67,
                "aspnet_score": 9, "console_score": 0, "tests_score": 0,
                "source_clipped": ""},
    "prescan_findings": [
        {"id": "A2-1", "rule": "A2", "severity": "high", "line": 31,
         "title": "Blocking on a task (.Result / .Wait / GetAwaiter().GetResult)",
         "evidence": "var order = _repo.FindAsync(id).Result;"},
    ],
}
est = api("POST", "/estimate", payload)
print(est["model"], est["model_alias"], est["hold_credits"], est["min_credits"])
const source = await fs.promises.readFile("OrderService.cs", "utf8");

const payload = {
  source,
  shape: "aspnet",
  shape_evidence: "[ApiController], app.MapPost and Microsoft.AspNetCore usings.",
  focus: "all",
  context: "Orders endpoint, ~400 req/s. Worried about the error handling.",
  prescan: { files: 1, types: 1, methods: 4, code_lines: 67,
             aspnet_score: 9, console_score: 0, tests_score: 0, source_clipped: "" },
  prescan_findings: [
    { id: "A2-1", rule: "A2", severity: "high", line: 31,
      title: "Blocking on a task (.Result / .Wait / GetAwaiter().GetResult)",
      evidence: "var order = _repo.FindAsync(id).Result;" },
  ],
};
const est = await api("POST", "/estimate", payload);
console.log(est.model, est.hold_credits, est.min_credits);
source, err := os.ReadFile("OrderService.cs")
if err != nil {
	panic(err)
}

payload := map[string]any{
	"source":           string(source),
	"shape":            "aspnet",
	"shape_evidence":   "[ApiController], app.MapPost and Microsoft.AspNetCore usings.",
	"focus":            "all",
	"context":          "Orders endpoint, ~400 req/s.",
	"prescan_findings": []any{},
}

var est struct {
	Model       string `json:"model"`
	HoldCredits int64  `json:"hold_credits"`
	MinCredits  int64  `json:"min_credits"`
}
if err := call("POST", "/estimate", payload, &est); err != nil {
	panic(err)
}
fmt.Println(est.Model, est.HoldCredits, est.MinCredits)
String source = Files.readString(Path.of("OrderService.cs"));

String payload = Json.object(
    "source", source,
    "shape", "aspnet",
    "shape_evidence", "[ApiController], app.MapPost and Microsoft.AspNetCore usings.",
    "focus", "all",
    "context", "Orders endpoint, ~400 req/s.",
    "prescan_findings", List.of());

String est = Api.call("POST", "/estimate", payload);
System.out.println(est);   // model, model_alias, markup_bps, hold_credits, min_credits
source = File.read("OrderService.cs")

payload = {
  "source"           => source,
  "shape"            => "aspnet",
  "shape_evidence"   => "[ApiController], app.MapPost and Microsoft.AspNetCore usings.",
  "focus"            => "all",
  "context"          => "Orders endpoint, ~400 req/s.",
  "prescan_findings" => [],
}
est = api("POST", "/estimate", payload)
puts est["model"], est["hold_credits"], est["min_credits"]
<?php
$source = file_get_contents("OrderService.cs");

$payload = [
    "source"           => $source,
    "shape"            => "aspnet",
    "shape_evidence"   => "[ApiController], app.MapPost and Microsoft.AspNetCore usings.",
    "focus"            => "all",
    "context"          => "Orders endpoint, ~400 req/s.",
    "prescan_findings" => [],
];
$est = api("POST", "/estimate", $payload);
echo $est["model"], " ", $est["hold_credits"], " ", $est["min_credits"], "\n";
var source = await File.ReadAllTextAsync("OrderService.cs");

var payload = new {
    source,
    shape = "aspnet",
    shape_evidence = "[ApiController], app.MapPost and Microsoft.AspNetCore usings.",
    focus = "all",
    context = "Orders endpoint, ~400 req/s.",
    prescan_findings = Array.Empty<object>(),
};
var est = await Api.Call(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"{est.GetProperty("model")} {est.GetProperty("hold_credits")}");

The three assertions worth making in a test: model is gpt-5.6-terra, model_alias is gpt-terra, and markup_bps is 1000. Together they prove the app is bound to the right model at the right markup, and they cost nothing to check.

Step 4 — Run and poll

POST /run creates a job; GET /jobs/{id} polls it to a terminal state. Always send an Idempotency-Key, and build it from three parts: a hash of the input, a value that is fresh for each run you intend to make, and an attempt counter. Re-POSTing with the same key returns the original job instead of billing a second one — that is what makes a network retry safe. It is also why the fresh-per-run part matters: a key built from the input alone means a deliberate second review of unchanged code silently replays the first answer instead of running, and there is no way to ask for a new one. Reuse the key to retry; change it to re-run. The completed job's output.output is the review as a JSON string.

# The Idempotency-Key makes a retried POST return the original job instead of
# billing a second one. Content hash + a per-run nonce + an attempt counter:
# reuse the whole key to RETRY, mint a new nonce to RE-RUN unchanged input.
HASH=$(printf %s "$SOURCE" | shasum -a 256 | cut -c1-16)
NONCE=$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n')
KEY="dotnet-clinic:$HASH:$NONCE:a1"

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

# Poll until terminal.
while :; do
  J=$(curl -s "$API/jobs/$JOB" -H "Authorization: Bearer $TOKEN")
  S=$(echo "$J" | jq -r .data.status)
  [ "$S" = "succeeded" ] || [ "$S" = "failed" ] && break
  sleep 2
done
echo "$J" | jq -r .data.output.output | jq .   # the review, as JSON
import hashlib, secrets, time

# content hash + per-run nonce + attempt: reuse to retry, re-mint to re-run.
digest = hashlib.sha256(payload["source"].encode()).hexdigest()[:16]
nonce = secrets.token_hex(8)
key = f"dotnet-clinic:{digest}:{nonce}:a1"
job = api("POST", "/run", payload, **{"Idempotency-Key": key})

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

review = json.loads(j["output"]["output"])
print(review["verdict"], len(review["findings"]), "findings")
import { createHash, randomBytes } from "node:crypto";

// content hash + per-run nonce + attempt: reuse to retry, re-mint to re-run.
const digest = createHash("sha256").update(payload.source).digest("hex").slice(0, 16);
const nonce = randomBytes(8).toString("hex");
const key = `dotnet-clinic:${digest}:${nonce}:a1`;
const job = await api("POST", "/run", payload, { "Idempotency-Key": key });

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

const review = JSON.parse(j.output.output);
console.log(review.verdict, review.findings.length, "findings");
import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"time"
)

// content hash + per-run nonce + attempt: reuse to retry, re-mint to re-run.
sum := sha256.Sum256([]byte(source))
nonceBytes := make([]byte, 8)
rand.Read(nonceBytes)
key := "dotnet-clinic:" + hex.EncodeToString(sum[:])[:16] + ":" + hex.EncodeToString(nonceBytes) + ":a1"

// call() with an extra header — add req.Header.Set("Idempotency-Key", key) there.
var job struct {
	JobID string `json:"job_id"`
}
if err := call("POST", "/run", payload, &job); err != nil {
	panic(err)
}

var j struct {
	Status string `json:"status"`
	Output struct {
		Output string `json:"output"`
	} `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+job.JobID, nil, &j); err != nil {
		panic(err)
	}
	if j.Status == "succeeded" || j.Status == "failed" {
		break
	}
	time.Sleep(2 * time.Second)
}
fmt.Println(j.Output.Output) // the review, as a JSON string
// Add the header inside Api.call(), or build the request inline:
var runReq = HttpRequest.newBuilder(URI.create(Api.BASE + "/run"))
        .header("Authorization", "Bearer " + Api.TOKEN)
        .header("Content-Type", "application/json")
        // content hash + per-run nonce + attempt: reuse to retry, re-mint to re-run.
        .header("Idempotency-Key", "dotnet-clinic:" + Integer.toHexString(source.hashCode())
                + ":" + java.util.UUID.randomUUID().toString().substring(0, 16) + ":a1")
        .POST(HttpRequest.BodyPublishers.ofString(payload))
        .build();
var runRes = Api.HTTP.send(runReq, HttpResponse.BodyHandlers.ofString());
// read data.job_id, then poll GET /jobs/{job_id} until status is succeeded or failed.
String job = Api.call("GET", "/jobs/" + jobId, null);
System.out.println(job);
require "digest"
require "securerandom"

# content hash + per-run nonce + attempt: reuse to retry, re-mint to re-run.
key = "dotnet-clinic:#{Digest::SHA256.hexdigest(payload["source"])[0, 16]}:#{SecureRandom.hex(8)}:a1"
job = api("POST", "/run", payload, { "Idempotency-Key" => key })

loop do
  @j = api("GET", "/jobs/#{job["job_id"]}")
  break if %w[succeeded failed].include?(@j["status"])
  sleep 2
end

review = JSON.parse(@j["output"]["output"])
puts review["verdict"], review["findings"].length
<?php
// content hash + per-run nonce + attempt: reuse to retry, re-mint to re-run.
$key = "dotnet-clinic:" . substr(hash("sha256", $payload["source"]), 0, 16)
     . ":" . bin2hex(random_bytes(8)) . ":a1";
$job = api("POST", "/run", $payload, ["Idempotency-Key" => $key]);

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

$review = json_decode($j["output"]["output"], true);
echo $review["verdict"], " ", count($review["findings"]), " findings\n";
using System.Security.Cryptography;

// content hash + per-run nonce + attempt: reuse to retry, re-mint to re-run.
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(source)))[..16].ToLower();
var nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLower();
var job = await Api.Call(HttpMethod.Post, "/run", payload, ("Idempotency-Key", $"dotnet-clinic:{hash}:{nonce}:a1"));
var jobId = job.GetProperty("job_id").GetString();

JsonElement j;
do {
    await Task.Delay(2000);
    j = await Api.Call(HttpMethod.Get, $"/jobs/{jobId}");
} while (j.GetProperty("status").GetString() is not ("succeeded" or "failed"));

var review = JsonDocument.Parse(j.GetProperty("output").GetProperty("output").GetString()!).RootElement;
Console.WriteLine(review.GetProperty("verdict"));

Step 5 — Stream instead (SSE)

POST /run-stream is the same call with Accept: text/event-stream. It emits an event: job frame, then a sequence of event: delta frames whose data.text carries fragments of the JSON in order, then event: done with the settled charge (or event: error). The frame name is on the event: line — the data: payload carries no type field of its own, so track the current event name as you read. This is what the app itself uses, and it is what lets a progress UI advance on real signals — the arrival of "verdict" or "findings" in the stream — rather than on a timer. Concatenate every delta and parse the whole thing once at the end.

curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" -H "Idempotency-Key: $KEY" \
  -d @payload.json

# Standard SSE frames, separated by a blank line. The frame NAME is on the
# `event:` line; the payload is on the `data:` line and carries no type field of
# its own, so you must track the current event name as you read:
#
#   event: job
#   data: {"job_id":"job_..."}
#
#   event: delta
#   data: {"text":"{\"review_title\":\"..."}
#
#   event: done
#   data: {"status":"succeeded","charged_credits":812,"output":{"output":"..."}}
#
# Concatenate every delta's `text` in order and parse the result as one JSON
# object. An `event: error` frame carries a failure instead.
with requests.post(API + "/run-stream", json=payload, stream=True,
                   headers={"Authorization": f"Bearer {TOKEN}",
                            "Accept": "text/event-stream",
                            "Idempotency-Key": key}) as res:
    raw, event, done = "", "message", None
    for line in res.iter_lines(decode_unicode=True):
        if line is None:
            continue
        if line == "":                          # blank line ends a frame
            event = "message"
            continue
        if line.startswith("event:"):
            event = line[6:].strip()
        elif line.startswith("data:"):
            data = json.loads(line[5:].strip())
            if event == "delta":
                raw += data.get("text", "")
            elif event == "done":
                done = data
            elif event == "error":
                raise RuntimeError(data.get("message", "stream failed"))

review = json.loads(raw[raw.index("{"): raw.rindex("}") + 1])
print(review["review_title"], review["verdict"], done["charged_credits"])
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${token}`,
    "Content-Type": "application/json",
    Accept: "text/event-stream",
    "Idempotency-Key": key,
  },
  body: JSON.stringify(payload),
});

let raw = "", buf = "", done = null;
const reader = res.body.getReader();
const dec = new TextDecoder();
for (;;) {
  const chunk = await reader.read();
  if (chunk.done) break;
  buf += dec.decode(chunk.value, { stream: true });
  let idx;
  while ((idx = buf.indexOf("\n\n")) >= 0) {     // frames are blank-line separated
    const frame = buf.slice(0, idx);
    buf = buf.slice(idx + 2);
    let event = "message", dataStr = "";
    for (const line of frame.split("\n")) {
      if (line.startsWith("event:")) event = line.slice(6).trim();
      else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
    }
    if (!dataStr) continue;
    const data = JSON.parse(dataStr);
    if (event === "delta") raw += data.text ?? "";
    else if (event === "done") done = data;
    else if (event === "error") throw new Error(data.message ?? "stream failed");
  }
}
const review = JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1));
console.log(review.review_title, review.verdict, done?.charged_credits);
import (
	"bufio"
	"strings"
)

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("Accept", "text/event-stream")
req.Header.Set("Idempotency-Key", key)

stream, _ := http.DefaultClient.Do(req)
defer stream.Body.Close()

var raw bytes.Buffer
event := "message"
sc := bufio.NewScanner(stream.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
	line := sc.Text()
	switch {
	case line == "":
		event = "message" // blank line ends the frame
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(line[6:])
	case strings.HasPrefix(line, "data:"):
		var d struct {
			Text string `json:"text"`
		}
		json.Unmarshal([]byte(line[5:]), &d)
		if event == "delta" {
			raw.WriteString(d.Text)
		}
	}
}
fmt.Println(raw.String()) // the review, as one JSON document
var streamReq = HttpRequest.newBuilder(URI.create(Api.BASE + "/run-stream"))
        .header("Authorization", "Bearer " + Api.TOKEN)
        .header("Content-Type", "application/json")
        .header("Accept", "text/event-stream")
        .header("Idempotency-Key", key)
        .POST(HttpRequest.BodyPublishers.ofString(payload))
        .build();

var raw = new StringBuilder();
var event = new String[]{"message"};
Api.HTTP.send(streamReq, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
    if (line.isEmpty()) {
        event[0] = "message";                 // blank line ends the frame
    } else if (line.startsWith("event:")) {
        event[0] = line.substring(6).trim();
    } else if (line.startsWith("data:") && event[0].equals("delta")) {
        // parse {"text":"..."} with your JSON library, then append the text
        raw.append(line.substring(5).trim());
    }
});
System.out.println(raw);
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"]   = "Bearer #{TOKEN}"
req["Content-Type"]    = "application/json"
req["Accept"]          = "text/event-stream"
req["Idempotency-Key"] = key
req.body = JSON.dump(payload)

raw   = +""
buf   = +""
event = "message"
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      buf << chunk
      while (i = buf.index("\n\n"))
        frame = buf.slice!(0, i + 2)
        frame.each_line do |line|
          line = line.chomp
          if line.start_with?("event:")
            event = line[6..].strip
          elsif line.start_with?("data:") && event == "delta"
            raw << (JSON.parse(line[5..].strip)["text"] || "")
          end
        end
        event = "message"   # the frame ended
      end
    end
  end
end

review = JSON.parse(raw[raw.index("{")..raw.rindex("}")])
puts review["review_title"]
<?php
$raw   = "";
$buf   = "";
$event = "message";

$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Accept: text/event-stream",
        "Idempotency-Key: $key",
    ],
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$buf, &$event) {
        $buf .= $chunk;
        while (($i = strpos($buf, "\n\n")) !== false) {
            $frame = substr($buf, 0, $i);
            $buf   = substr($buf, $i + 2);
            foreach (explode("\n", $frame) as $line) {
                if (str_starts_with($line, "event:")) {
                    $event = trim(substr($line, 6));
                } elseif (str_starts_with($line, "data:") && $event === "delta") {
                    $d = json_decode(substr($line, 5), true);
                    $raw .= $d["text"] ?? "";
                }
            }
            $event = "message";   // the frame ended
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$start = strpos($raw, "{");
$review = json_decode(substr($raw, $start, strrpos($raw, "}") - $start + 1), true);
echo $review["review_title"], "\n";
var streamReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
streamReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
streamReq.Headers.Add("Accept", "text/event-stream");
streamReq.Headers.Add("Idempotency-Key", key);
streamReq.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");

var streamRes = await new HttpClient().SendAsync(streamReq, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await streamRes.Content.ReadAsStreamAsync());

var raw = new StringBuilder();
var evt = "message";
while (await reader.ReadLineAsync() is { } line) {
    if (line.Length == 0) { evt = "message"; continue; }   // blank line ends the frame
    if (line.StartsWith("event:")) { evt = line[6..].Trim(); continue; }
    if (!line.StartsWith("data:")) continue;
    var data = JsonDocument.Parse(line[5..]).RootElement;
    if (evt == "delta") raw.Append(data.GetProperty("text").GetString());
}
Console.WriteLine(raw.ToString());

If the stream dies mid-review you still hold every delta received so far. Slicing from the first { and appending the closing brackets recovers a partial review often enough to be worth trying before you show an error.

Step 6 — Your saved reviews

Every run the app completes is written to the reviews collection (acl_read: owner, acl_write: user), so a review follows the user across devices. Six fields are declared and therefore filterable and orderable: title, shape, verdict, finding_count, high_count and ran_at. The rest of the document — the whole review and the (trimmed) source it was run against — round-trips intact but is not indexed.

OperatorUse
eq, neExact match, e.g. {"verdict": {"eq": "rework"}}.
lt, lte, gt, gteRanges over numbers and timestamps.
inUp to 20 values.
containsSubstring match on a string field.
# List your saved reviews, newest first.
curl -s -X POST "$API/collections/reviews/query" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"order_by":[{"field":"ran_at","dir":"desc"}],"limit":10}' | jq '.data.records[].doc.title'

# Only the ones that came back rework with something genuinely serious in them.
curl -s -X POST "$API/collections/reviews/query" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"where":{"verdict":{"eq":"rework"},"high_count":{"gte":2}},
       "order_by":[{"field":"ran_at","dir":"desc"}],"limit":20}' | jq .data
# Every `where` entry must be an operator object — the bare-value shorthand
# ({"verdict": "rework"}) is rejected.
recent = api("POST", "/collections/reviews/query", {
    "where": {"verdict": {"eq": "rework"}, "high_count": {"gte": 2}},
    "order_by": [{"field": "ran_at", "dir": "desc"}],
    "limit": 20,
})
for rec in recent["records"]:
    d = rec["doc"]
    print(d["ran_at"], d["title"], d["finding_count"], "findings", d["high_count"], "high")
const recent = await api("POST", "/collections/reviews/query", {
  where: { verdict: { eq: "rework" }, high_count: { gte: 2 } },
  order_by: [{ field: "ran_at", dir: "desc" }],
  limit: 20,
});
for (const rec of recent.records) {
  const d = rec.doc;
  console.log(d.ran_at, d.title, d.finding_count, d.high_count);
}
query := map[string]any{
	"where":    map[string]any{"verdict": map[string]any{"eq": "rework"}},
	"order_by": []map[string]string{{"field": "ran_at", "dir": "desc"}},
	"limit":    20,
}

var out struct {
	Records []struct {
		RecordID string         `json:"record_id"`
		Doc      map[string]any `json:"doc"`
	} `json:"records"`
}
if err := call("POST", "/collections/reviews/query", query, &out); err != nil {
	panic(err)
}
for _, r := range out.Records {
	fmt.Println(r.Doc["ran_at"], r.Doc["title"])
}
String query = """
    {"where":{"verdict":{"eq":"rework"}},
     "order_by":[{"field":"ran_at","dir":"desc"}],"limit":20}
    """;
String reviews = Api.call("POST", "/collections/reviews/query", query);
System.out.println(reviews);   // {"data":{"records":[{"record_id":"...","doc":{...}}]}}
recent = api("POST", "/collections/reviews/query", {
  "where"    => { "verdict" => { "eq" => "rework" } },
  "order_by" => [{ "field" => "ran_at", "dir" => "desc" }],
  "limit"    => 20,
})
recent["records"].each { |r| puts "#{r["doc"]["ran_at"]} #{r["doc"]["title"]}" }
<?php
$recent = api("POST", "/collections/reviews/query", [
    "where"    => ["verdict" => ["eq" => "rework"]],
    "order_by" => [["field" => "ran_at", "dir" => "desc"]],
    "limit"    => 20,
]);
foreach ($recent["records"] as $rec) {
    echo $rec["doc"]["ran_at"], " ", $rec["doc"]["title"], "\n";
}
var query = new {
    where = new { verdict = new { eq = "rework" } },
    order_by = new[] { new { field = "ran_at", dir = "desc" } },
    limit = 20,
};
var recent = await Api.Call(HttpMethod.Post, "/collections/reviews/query", query);
foreach (var rec in recent.GetProperty("records").EnumerateArray())
    Console.WriteLine(rec.GetProperty("doc").GetProperty("title"));

Every where entry must be an operator object. The bare-value shorthand {"verdict": "rework"} is rejected with where.verdict must be an object of operators. Note also that each POST /guest mints a new guest subject, and acl_read: owner scopes rows to the calling subject — so a script must reuse one token across create and query or it will see an empty collection.

The output contract

One JSON object. These are the fields the app's own render path parses; anything missing makes the app fall back to showing the raw reply, so treat them as required.

FieldTypeMeaning
review_titlestringNames what was reviewed.
shapestringaspnet, console, library or tests. Anything else is rejected.
shape_reasonstringOne sentence echoing or correcting the request's shape.
verdictstringship-it, tidy-first or rework. Anything else is rejected.
headlinestringOne sentence justifying the verdict.
summarystringTwo to four sentences a reviewer could paste into the pull request.
findingsarrayMay be [] — that is what a clean review looks like — but never omitted. Entries are {id, severity, rule, title, location, evidence, why, fix, patch_before, patch_after}.
findings[].severitystringhigh, medium or low. The app sorts high first.
findings[].rulestringOne of naming, docs, async, exceptions, di, disposal, logging, nulls, config, security, smells, testing, layout.
findings[].evidencestringA verbatim line from the request's source. The app checks each one, whitespace-normalised, and labels anything it cannot locate as unverified rather than showing it as fact. Assert on this if you post-process.
prescan_checkarray{id, status, note}, exactly one per id in the request's prescan_findings. status is confirmed or set-aside; anything the review never mentions is shown to the user as unadjudicated.
strengths, next_stepsarray<string>May be empty arrays, but never omitted. A rework verdict with empty next_steps is a contract violation.
package_layoutobject{assessment, suggested_tree[]} — the namespace and project layout read, one path per tree entry, e.g. Services/OrderService.cs.
test_planarray{target, kind, annotation, why}. kind is unit, integration or endpoint; annotation is something like [Fact] or WebApplicationFactory<Program>, and may be "".

Two invariants worth asserting on in a pipeline. First, every findings[].evidence should be locatable in the source you sent — if it is not, the finding is describing code that is not yours. Second, the set of prescan_check ids should equal the set of prescan_findings ids you sent; a mismatch means the review either skipped one of your scanner's results or invented an id. The app renders both conditions rather than hiding them, and so should you.

Rendering it yourself

The two CSV exports the app ships are pure functions of the payloads, so they are easy to reproduce server-side. The findings worksheet flattens findings[] into id, severity, rule, location, title, why, fix, evidence, status — one row per finding, with a status column pre-filled as open for whoever works the queue. The browser-scan export flattens your own prescan_findings[] into id, severity, rule, file, line, title, detail, evidence, which is the artifact to diff across runs: it is deterministic, so a second run on edited source shows exactly which mechanical findings cleared. Fields containing a comma, a quote or a newline are quoted and internal quotes doubled, per RFC 4180.