Anatomy Atelier — API & tutorial Open the app

Mark a recitation from your own code

The 3D atlas, the organ reference data and the client-side term prescan are free and need no account. The Study Desk — where a learner's written recall is graded against a fixed six-criterion anatomy rubric — runs through the SkillSafe App API: plain JSON over HTTPS, with optional streaming. Every step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api. Every request sends Authorization: Bearer <token>, and JSON bodies carry Content-Type: application/json. Responses are always wrapped in an envelope: {"data": …} on success, {"error": {"code", "message"}} on failure. A grading run bills SkillSafe credits to the calling token — a worst-case hold goes up front and the actual cost settles when the job finishes. The app itself has price_credits 0, so what you pay is model cost plus the app's markup_bps, nothing more.

StatusCodeWhat it means here
400VALIDATION_ERROR The input JSON is malformed or a field is the wrong type — usually a missing recitation, a missing organ_brief, or a mode / level outside the allowed sets.
401UNAUTHORIZED Missing, malformed or expired token. Mint a fresh one (step 1) and retry.
402PAYMENT_REQUIRED Balance is below the hold this run needs. Call /estimate first and top up at skillsafe.ai/account/billing. Nothing is charged when a run is refused.
404NOT_FOUND Unknown job_id, or a data key that was never written. A missing history key is normal on a first run — treat it as an empty list.
409CONFLICT An Idempotency-Key you already used is being replayed with a different body. Same key + same body returns the original job instead of charging twice; change the key when the recitation changes.
429RATE_LIMITED Too many calls too quickly. Back off exponentially; do not tight-loop the job poll.
500INTERNAL Transient platform error. Retry with backoff, reusing the same Idempotency-Key so a run that actually started is not duplicated.

Browsers enforce CORS on this API, so run these examples from a terminal, script or server — not from another website's frontend. Treat the token like a password: it can spend your credits.

Step 1 — Get a token

POST /guest
free

Open the token page and press Copy shell export — it puts export SKILLSAFE_TOKEN="…" on your clipboard, which is exactly what the examples below read. Sign in there first if you want the run billed to your own credits and its history saved to your account. For a fully headless script, POST /guest mints a guest token with no browser involved; a guest can check its balance and estimate freely, and can run if it has credits.

export API="https://api.skillsafe.ai/v1/app-api"

# either paste the export from /tokens.html:
export SKILLSAFE_TOKEN="YOUR_TOKEN"

# …or mint a guest token headlessly:
export SKILLSAFE_TOKEN=$(curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"anatomy-atelier"}' | jq -r '.data.token')
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # from https://anatomy-atelier.skillsafe.ai/tokens.html

# headless alternative — a guest token, no browser:
import requests
TOKEN = requests.post(API + "/guest", json={"slug": "anatomy-atelier"}) \
                .json()["data"]["token"]
const API = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://anatomy-atelier.skillsafe.ai/tokens.html

// headless alternative — a guest token, no browser:
const res = await fetch(API + "/guest", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ slug: "anatomy-atelier" }),
});
TOKEN = (await res.json()).data.token;
const API = "https://api.skillsafe.ai/v1/app-api"

// Read the token minted at /tokens.html from the environment:
var token = os.Getenv("SKILLSAFE_TOKEN")

// …or mint a guest token headlessly (uses call() from step 2):
var guest struct{ Token string `json:"token"` }
if token == "" {
	if err := call("POST", "/guest", map[string]string{"slug": "anatomy-atelier"}, &guest); err != nil {
		log.Fatal(err)
	}
	token = guest.Token
}
static final String API = "https://api.skillsafe.ai/v1/app-api";

// Token copied from /tokens.html, read from the environment:
static String TOKEN = System.getenv("SKILLSAFE_TOKEN");

// …or mint a guest token headlessly (uses api() from step 2):
// String envelope = api("POST", "/guest", "{\"slug\":\"anatomy-atelier\"}");
// the token is at data.token in the returned JSON
API = "https://api.skillsafe.ai/v1/app-api"

# Token copied from /tokens.html:
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")

# …or mint a guest token headlessly (uses api() from step 2):
# TOKEN = api("POST", "/guest", { slug: "anatomy-atelier" })["token"]
<?php
const API = "https://api.skillsafe.ai/v1/app-api";

// Token copied from /tokens.html:
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";

// …or mint a guest token headlessly (uses api() from step 2):
// $TOKEN = api("POST", "/guest", ["slug" => "anatomy-atelier"])["token"];
const string Api = "https://api.skillsafe.ai/v1/app-api";

// Token copied from /tokens.html:
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";

// …or mint a guest token headlessly (uses SkillSafe.ApiAsync from step 2):
// var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
//     new { slug = "anatomy-atelier" });
// token = guest.GetProperty("token").GetString();

No DevTools required — /tokens.html shows your session state, the masked token, reveal / copy / copy-shell-export buttons, and a force-new-guest button.

Step 2 — A tiny client

Every call is one HTTP request, so start with a small helper that adds the auth header, sends JSON and unwraps the data envelope. Steps 3 to 7 reuse it.

# every call looks like:
#   curl -s "$API/…" -H "Authorization: Bearer $SKILLSAFE_TOKEN" [-d '{json}']
# jq pulls fields out of the {"data": …} envelope:
#   … | jq '.data'
# add -H "Content-Type: application/json" on any POST with a body.
import json, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # see step 1

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:
        err = payload.get("error", {})
        raise RuntimeError(f"{err.get('code', res.status_code)}: {err.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 own config 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?.code ?? res.status}: ${json.error?.message ?? res.statusText}`);
  return json.data;
}
package main

import (
	"bufio"
	"bytes"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"strings"
	"time"
)

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 {
			Code    string `json:"code"`
			Message string `json:"message"`
		} `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 {
		return fmt.Errorf("api %s %s: %s: %s", method, path, env.Error.Code, 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 AnatomyAtelier {
    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, extra = {})
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  extra.each { |k, v| req[k] = v }
  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)
  unless res.is_a?(Net::HTTPSuccess)
    raise "#{payload.dig("error", "code")}: #{payload.dig("error", "message") || res.message}"
  end
  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 = []): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => array_merge([
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ], $extra),
        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"]["code"] ?? "HTTP $status")
            . ": " . ($payload["error"]["message"] ?? ""));
    }
    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,
        (string Name, string Value)[]? extraHeaders = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (body != null) req.Content = JsonContent.Create(body);
        if (extraHeaders != null)
            foreach (var h in extraHeaders) req.Headers.Add(h.Name, h.Value);
        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 3 — Check the session and your balance

GET /me
free

Returns subject_type ("user" or "guest"), subject_id and the credits balance for the calling token. Call it first: it tells you whether the token you pasted is your personal session or a stale guest, and whether there is enough balance for the hold in step 4.

curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq '.data'
# {"subject_type":"user","subject_id":"usr_…","credits":41250}
me = api("GET", "/me")
print(me["subject_type"], me["subject_id"], me["credits"], "credits")
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, me.credits, "credits");
var me struct {
	SubjectType string `json:"subject_type"`
	SubjectID   string `json:"subject_id"`
	Credits     int64  `json:"credits"`
}
if err := call("GET", "/me", nil, &me); err != nil {
	log.Fatal(err)
}
fmt.Println(me.SubjectType, me.Credits)
String envelope = api("GET", "/me", null);
// data.subject_type ("user" | "guest"), data.subject_id, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]} #{me["subject_id"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']} {$me['subject_id']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");

Step 4 — Estimate the cost (free)

POST /estimate
free

Send the exact body you would send to /run. Nothing is charged, no job is created, and no model is called. The response tells you which model will run and what the worst case costs — assert on it in your scripts so a silent model swap or a pricing change cannot surprise you:

Response fieldExpected for this appNotes
model_alias"gpt-terra"The alias the app pins. Assert on this, not on model.
modelthe concrete model the alias currently resolves toMay change as the platform moves the alias forward; log it, don't hard-code it.
markup_bps1000Basis points added on top of model cost (10%).
hold_creditsintegerWorst-case cost — this is what /run holds. Compare it against /me's credits and refuse the run yourself rather than eating a 402.
min_creditsintegerFloor for the run, when reported.

The price_credits for this app is 0: there is no per-run app fee on top of model cost and markup.

# input.json is the full run body — see step 5 for every field.
curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -d @input.json | jq '.data | {model, model_alias, markup_bps, hold_credits, min_credits}'

# assert before spending:
jq -e '.data.model_alias == "gpt-terra" and .data.markup_bps == 1000' <<<"$EST" >/dev/null \
  || echo "unexpected pricing shape — check before running"
est = api("POST", "/estimate", payload)   # payload built in step 5

assert est["model_alias"] == "gpt-terra", est["model_alias"]
assert est["markup_bps"] == 1000, est["markup_bps"]

hold = est["hold_credits"]
print(f"{est['model']} (alias {est['model_alias']}), worst case {hold} credits")

if hold > api("GET", "/me")["credits"]:
    raise SystemExit("not enough credits — top up before running")
const est = await api("POST", "/estimate", payload); // payload built in step 5

if (est.model_alias !== "gpt-terra") throw new Error(`unexpected alias ${est.model_alias}`);
if (est.markup_bps !== 1000) throw new Error(`unexpected markup ${est.markup_bps}`);

console.log(`${est.model} (alias ${est.model_alias}), worst case ${est.hold_credits} credits`);

const { credits } = await api("GET", "/me");
if (est.hold_credits > credits) throw new Error("not enough credits — top up before running");
var est struct {
	Model       string `json:"model"`
	ModelAlias  string `json:"model_alias"`
	MarkupBps   int64  `json:"markup_bps"`
	HoldCredits int64  `json:"hold_credits"`
	MinCredits  int64  `json:"min_credits"`
}
if err := call("POST", "/estimate", payload, &est); err != nil { // payload from step 5
	log.Fatal(err)
}
if est.ModelAlias != "gpt-terra" || est.MarkupBps != 1000 {
	log.Fatalf("unexpected pricing: %s / %d bps", est.ModelAlias, est.MarkupBps)
}
fmt.Printf("%s, worst case %d credits\n", est.Model, est.HoldCredits)
String envelope = api("POST", "/estimate", payloadJson); // payloadJson from step 5
// Read these out with your JSON library and assert before spending:
//   data.model_alias  == "gpt-terra"
//   data.markup_bps   == 1000
//   data.hold_credits <= the credits reported by GET /me
//   data.model, data.min_credits are informational — log them.
est = api("POST", "/estimate", payload) # payload from step 5

raise "unexpected alias #{est["model_alias"]}" unless est["model_alias"] == "gpt-terra"
raise "unexpected markup #{est["markup_bps"]}" unless est["markup_bps"] == 1000

puts "#{est["model"]}, worst case #{est["hold_credits"]} credits"
abort "not enough credits" if est["hold_credits"] > api("GET", "/me")["credits"]
$est = api("POST", "/estimate", $payload); // $payload from step 5

if (($est["model_alias"] ?? "") !== "gpt-terra") {
    throw new Exception("unexpected alias " . ($est["model_alias"] ?? "?"));
}
if (($est["markup_bps"] ?? 0) !== 1000) {
    throw new Exception("unexpected markup " . ($est["markup_bps"] ?? "?"));
}
echo "{$est['model']}, worst case {$est['hold_credits']} credits\n";

if ($est["hold_credits"] > api("GET", "/me")["credits"]) {
    throw new Exception("not enough credits — top up before running");
}
var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload); // step 5

var alias = est.GetProperty("model_alias").GetString();
var markup = est.GetProperty("markup_bps").GetInt32();
if (alias != "gpt-terra" || markup != 1000)
    throw new Exception($"unexpected pricing: {alias} / {markup} bps");

var hold = est.GetProperty("hold_credits").GetInt32();
Console.WriteLine($"{est.GetProperty("model")}, worst case {hold} credits");

var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
if (hold > me.GetProperty("credits").GetInt32())
    throw new Exception("not enough credits — top up before running");

Step 5 — Grade a recitation and wait for the result

POST /run
GET /jobs/{job_id}

/run places the credit hold and returns a job_id. Poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed. Always send an Idempotency-Key header: a network retry with the same key and the same body returns the original job instead of starting a second, double-charged run (a different body under the same key is a 409 CONFLICT).

Input body

FieldTypeNotes
organstring, requiredThe atlas organ id, e.g. "heart".
organ_briefobject, requiredThe atlas's reference data for that organ — the model's ground truth. Keys: name, scientific_name, system, size, weight, location, function, blood_supply, tissue, daily_fact, medical, and conditions (string array). Corrections are grounded in this and nothing else.
recitationstring, requiredThe learner's own words — what is actually being graded.
mode"recitation" | "notes" | "exam"recitation grades understanding and forgives loose wording; notes grades coverage over phrasing; exam is graded strictly and gets comments on structure.
level"beginner" | "student" | "clinical"Sets the bar and the pitch of corrections and flashcards. A beginner is not penalised for omitting vessel names; a clinical learner is.
prescanobjectWhat the app's free client-side term scan found first: word_count (number), terms_total (number), terms_hit / terms_missed (string arrays), and criteria_touched (a subset of the six criterion ids). It is a hint, not an instruction — a concept explained in plain words without the technical term still earns credit.
clippedbooleanTrue when you shortened a long input before sending. Then a criterion is marked partial rather than missing on absence alone.
clip_notestringWhat was cut, e.g. "trailing 1,400 words dropped". Empty string when clipped is false.
retry_notestring, optionalOnly on the reformat retry lane: describe how the previous reply was malformed and it will be obeyed exactly.
{
  "organ": "heart",
  "organ_brief": {
    "name": "Heart",
    "scientific_name": "Cor",
    "system": "Cardiovascular",
    "size": "About the size of your fist",
    "weight": "250-350 g",
    "location": "Behind the sternum, slightly left",
    "function": "Circulates oxygenated blood",
    "blood_supply": "Left and right coronary arteries",
    "tissue": "Cardiac muscle tissue",
    "daily_fact": "Beats about 100,000 times",
    "medical": "Its electrical rhythm coordinates every heartbeat.",
    "conditions": ["Coronary artery disease", "Arrhythmia"]
  },
  "recitation": "The heart sits behind the breastbone, tipped left…",
  "mode": "recitation",
  "level": "student",
  "prescan": {
    "word_count": 210,
    "terms_total": 12,
    "terms_hit": ["coronary", "sternum"],
    "terms_missed": ["myocardium"],
    "criteria_touched": ["location", "blood_supply"]
  },
  "clipped": false,
  "clip_note": ""
}
# input.json holds the body shown above.
JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: study-heart-$(date +%s)" \
  -d @input.json | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done

# the grader's JSON object arrives as a string at data.output.output
echo "$JOB" | jq -r '.data.output.output' | jq '{verdict, score, headline}'
import time, uuid

payload = {
    "organ": "heart",
    "organ_brief": organ_brief,        # the atlas reference data for this organ
    "recitation": recitation,
    "mode": "recitation",
    "level": "student",
    "prescan": prescan,                # word_count / terms_* / criteria_touched
    "clipped": False,
    "clip_note": "",
}

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": f"study-heart-{uuid.uuid4()}"})["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"))

study = parse_study(job["output"])     # see step 7
print(study["verdict"], study["score"], study["headline"])
const payload = {
  organ: "heart",
  organ_brief: organBrief,   // the atlas reference data for this organ
  recitation,
  mode: "recitation",        // "recitation" | "notes" | "exam"
  level: "student",          // "beginner" | "student" | "clinical"
  prescan,                   // word_count / terms_* / criteria_touched
  clipped: false,
  clip_note: "",
};

const { job_id } = await api("POST", "/run", payload, {
  "Idempotency-Key": `study-heart-${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 study = parseStudy(job.output);  // see step 7
console.log(study.verdict, study.score, study.headline);
payload := map[string]any{
	"organ":       "heart",
	"organ_brief": organBrief,
	"recitation":  recitation,
	"mode":        "recitation",
	"level":       "student",
	"prescan":     prescan,
	"clipped":     false,
	"clip_note":   "",
}

// call() does not set Idempotency-Key; add it to the request in your own
// wrapper — one stable key per distinct recitation.
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 struct {
		Output string `json:"output"`
	} `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)
}
if job.Status == "failed" {
	log.Fatal(job.Error)
}
// job.Output.Output is the grader's JSON object as a string — see step 7.
String body = """
    {"organ":"heart","organ_brief":%s,"recitation":%s,
     "mode":"recitation","level":"student","prescan":%s,
     "clipped":false,"clip_note":""}
    """.formatted(organBriefJson, toJsonString(recitation), prescanJson);

// Add the idempotency header in your own overload of api():
//   .header("Idempotency-Key", "study-heart-" + UUID.randomUUID())
String envelope = api("POST", "/run", body);
String jobId = /* data.job_id via your JSON library */;

String job;
while (true) {
    job = api("GET", "/jobs/" + jobId, null);
    String status = /* data.status */;
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}
// the grader's JSON object is the string at data.output.output — see step 7.
require "securerandom"

payload = {
  organ: "heart",
  organ_brief: organ_brief,
  recitation: recitation,
  mode: "recitation",
  level: "student",
  prescan: prescan,
  clipped: false,
  clip_note: "",
}

started = api("POST", "/run", payload,
              { "Idempotency-Key" => "study-heart-#{SecureRandom.uuid}" })

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"

study = parse_study(job["output"]) # see step 7
puts "#{study["verdict"]} #{study["score"]}: #{study["headline"]}"
$payload = [
    "organ"       => "heart",
    "organ_brief" => $organBrief,
    "recitation"  => $recitation,
    "mode"        => "recitation",
    "level"       => "student",
    "prescan"     => $prescan,
    "clipped"     => false,
    "clip_note"   => "",
];

$started = api("POST", "/run", $payload, [
    "Idempotency-Key: study-heart-" . bin2hex(random_bytes(8)),
]);

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

if ($job["status"] === "failed") {
    throw new Exception($job["error"] ?? "run failed");
}

$study = parse_study($job["output"]); // see step 7
echo "{$study['verdict']} {$study['score']}: {$study['headline']}\n";
var payload = new {
    organ = "heart",
    organ_brief = organBrief,
    recitation,
    mode = "recitation",
    level = "student",
    prescan,
    clipped = false,
    clip_note = "",
};

var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload,
    new[] { ("Idempotency-Key", $"study-heart-{Guid.NewGuid()}") });
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);
}

// The grader's JSON object is the string at job.output.output — see step 7.
var raw = job.GetProperty("output").GetProperty("output").GetString()!;

A finished job also reports charged_credits — the settled cost, which is at most the hold_credits from step 4 and usually less. The unused hold is released automatically.

Step 6 — The same run, streamed (SSE)

POST /run-stream

Identical body to /run, but the response is text/event-stream so you can show the rubric filling in as it is written — this is the path the app itself uses (runStream). Send the same Idempotency-Key discipline; on an idempotent replay the endpoint may answer with a plain JSON envelope instead of a stream, so check the response Content-Type before reaching for a reader.

EventData
job{job_id} — the run was accepted and the hold placed.
delta{text} — the next chunk of the grader's output. Concatenate for a live preview only.
done / pending{job_id, status, charged_credits, output}.
error{code, message, job_id} — the codes from the table at the top of this page.

The terminal done payload is authoritative. Deltas can drop the tail of the reply, so a rubric assembled purely from concatenated deltas may be missing its last criteria, its flashcards, or its closing brace. Render deltas optimistically if you like — the app parses partial text in tolerant mode to paint sections as they land — but take the value you keep, export or store from done.output.output.

curl -sN -X POST "$API/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: study-heart-$(date +%s)" \
  -d @input.json

# event: job    data: {"job_id":"job_…"}
# event: delta  data: {"text":"{\"verdict\":\"developing\""}
# event: delta  data: {"text":",\"score\":68,\"headline\":\"…"}
# …
# event: done   data: {"job_id":"job_…","status":"succeeded","charged_credits":318,
#                      "output":{"output":"{\"verdict\":\"developing\",…}"}}
res = requests.post(API + "/run-stream", json=payload, stream=True,
                    headers={"Authorization": f"Bearer {TOKEN}",
                             "Idempotency-Key": f"study-heart-{uuid.uuid4()}"})

event, done, preview = None, None, []
for line in res.iter_lines(decode_unicode=True):
    if not line:
        continue
    if line.startswith("event:"):
        event = line[6:].strip()
    elif line.startswith("data:"):
        data = json.loads(line[5:])
        if event == "delta":
            preview.append(data.get("text", ""))     # live paint only
        elif event in ("done", "pending"):
            done = data                              # authoritative
        elif event == "error":
            raise RuntimeError(f"{data.get('code')}: {data.get('message')}")

study = parse_study(done["output"])   # never from "".join(preview) — see step 7
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": `study-heart-${crypto.randomUUID()}`,
  },
  body: JSON.stringify(payload),
});

// An idempotent replay can answer with plain JSON instead of a stream:
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
  const json = await res.json();
  return parseStudy(json.data.output);
}

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", event = "message", preview = "", done;
for (;;) {
  const chunk = await reader.read();
  if (chunk.done) break;
  buf += decoder.decode(chunk.value, { stream: true });
  let i;
  while ((i = buf.indexOf("\n")) >= 0) {
    const line = buf.slice(0, i); buf = buf.slice(i + 1);
    if (line.startsWith("event:")) event = line.slice(6).trim();
    else if (line.startsWith("data:")) {
      const data = JSON.parse(line.slice(5));
      if (event === "delta") preview += data.text ?? "";   // live paint only
      else if (event === "done" || event === "pending") done = data;
      else if (event === "error") throw new Error(`${data.code}: ${data.message}`);
    }
  }
}

const study = parseStudy(done.output);  // authoritative — see step 7
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", "study-heart-"+idempotencySuffix)
res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()

sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 1<<20), 1<<20)
event, doneData := "", []byte(nil)
var preview strings.Builder
for sc.Scan() {
	line := sc.Text()
	if strings.HasPrefix(line, "event:") {
		event = strings.TrimSpace(line[6:])
	} else if strings.HasPrefix(line, "data:") {
		data := strings.TrimSpace(line[5:])
		switch event {
		case "delta":
			var d struct{ Text string `json:"text"` }
			json.Unmarshal([]byte(data), &d)
			preview.WriteString(d.Text) // live paint only
		case "done", "pending":
			doneData = []byte(data) // authoritative
		case "error":
			log.Fatal(data)
		}
	}
}
// Unmarshal doneData → .output.output (a JSON string) → parseStudy (step 7).
// Never unmarshal preview.String(): deltas can drop the tail.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "study-heart-" + UUID.randomUUID())
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();
var lines = HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body();

final String[] event = {""};
StringBuilder preview = new StringBuilder();
StringBuilder doneData = new StringBuilder();
lines.forEach(line -> {
    if (line.startsWith("event:")) event[0] = line.substring(6).trim();
    else if (line.startsWith("data:")) {
        String data = line.substring(5).trim();
        if (event[0].equals("delta")) preview.append(/* parse {"text"} */ data);
        else if (event[0].equals("done") || event[0].equals("pending")) doneData.append(data);
        else if (event[0].equals("error")) throw new RuntimeException(data);
    }
});
// Parse doneData → output.output (a JSON string) → the rubric (step 7).
// The concatenated preview is for display only; it can be truncated.
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "study-heart-#{SecureRandom.uuid}"
req.body = payload.to_json

event, done, preview, buf = nil, nil, +"", +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      buf << chunk
      while (i = buf.index("\n"))
        line = buf.slice!(0..i).chomp
        if line.start_with?("event:") then event = line[6..].strip
        elsif line.start_with?("data:")
          data = JSON.parse(line[5..])
          preview << data["text"].to_s if event == "delta" # live paint only
          done = data if %w[done pending].include?(event)
          raise "#{data["code"]}: #{data["message"]}" if event == "error"
        end
      end
    end
  end
end

study = parse_study(done["output"]) # authoritative — see step 7
$event = ""; $done = null; $preview = ""; $buf = "";
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Idempotency-Key: study-heart-" . bin2hex(random_bytes(8)),
    ],
    CURLOPT_POSTFIELDS    => json_encode($payload),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done, &$preview, &$buf) {
        $buf .= $chunk;
        while (($i = strpos($buf, "\n")) !== false) {
            $line = rtrim(substr($buf, 0, $i));
            $buf  = substr($buf, $i + 1);
            if (str_starts_with($line, "event:")) {
                $event = trim(substr($line, 6));
            } elseif (str_starts_with($line, "data:")) {
                $data = json_decode(substr($line, 5), true);
                if ($event === "delta") $preview .= $data["text"] ?? ""; // display only
                if ($event === "done" || $event === "pending") $done = $data;
                if ($event === "error") throw new Exception($data["message"] ?? "stream error");
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$study = parse_study($done["output"]); // authoritative — see step 7
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream")
    { Content = JsonContent.Create(payload) };
req.Headers.Add("Idempotency-Key", $"study-heart-{Guid.NewGuid()}");
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());

string? line; string ev = ""; var preview = new StringBuilder(); JsonElement doneEl = default;
while ((line = await reader.ReadLineAsync()) != null)
{
    if (line.StartsWith("event:")) ev = line[6..].Trim();
    else if (line.StartsWith("data:"))
    {
        var data = JsonDocument.Parse(line[5..]).RootElement.Clone();
        if (ev == "delta")
            preview.Append(data.TryGetProperty("text", out var t) ? t.GetString() : "");
        else if (ev is "done" or "pending") doneEl = data;   // authoritative
        else if (ev == "error")
            throw new Exception(data.GetProperty("message").GetString());
    }
}

// Read the result from doneEl, not from preview — deltas can drop the tail.
var raw = doneEl.GetProperty("output").GetProperty("output").GetString()!;

Step 7 — Parse the graded rubric

The grader replies with exactly one JSON object and nothing else, delivered as a string at output.output (occasionally at output directly). Models being models, wrap the parse: strip any code fences, take the outermost {…}, JSON.parse, then normalize. Those are the same steps the app's own parseStudy() performs before rendering, and reproducing them is what makes your client's output identical to the app's.

{
  "verdict": "mastered",
  "score": 84,
  "headline": "Strong on function and blood supply; the tissue layer is still thin.",
  "rubric": [
    {
      "id": "location",
      "criterion": "Location in the body",
      "status": "correct",
      "evidence": "\"sits behind the breastbone, tipped left\"",
      "correction": ""
    }
  ],
  "misconceptions": [
    { "claim": "the heart oxygenates blood", "why_wrong": "…", "correct": "…" }
  ],
  "missed": ["…"],
  "strengths": ["…"],
  "next_steps": ["…"],
  "flashcards": [{ "front": "…", "back": "…" }]
}

Top-level fields

FieldTypeNotes
verdict"mastered" | "developing" | "needs_review"Derived from score: ≥ 80 mastered, 50–79 developing, < 50 needs_review. Anything else is clamped.
scoreinteger 0–100Roughly 100 × (correct 1, partial 0.5, missing/incorrect 0) / 6, adjusted by at most ±8 for quality of explanation. Coerce to an integer and clamp to the range.
headlinestringOne sentence, specific to this learner's writing.
rubricarray — exactly six entriesSee below.
misconceptions{claim, why_wrong, correct}[]Every incorrect criterion also appears here. May be [].
missedstring[]Facts from organ_brief the learner never reached. May be [].
strengthsstring[]What was genuinely well said. Empty only when the recitation contained nothing correct.
next_stepsstring[]Concrete study actions. May be [].
flashcards{front, back}[]Typically 3–8 cards, aimed at the gaps the rubric found. May be []. front,back is the app's flashcards.csv export — Anki-importable as-is.

The six criteria — always all six, always in this order

#idcriterionWhat earns correct
1locationLocation in the bodyPlaces the organ correctly relative to landmarks.
2structureStructure and sizeGross form, chambers/lobes/layers as applicable, and a defensible sense of scale or weight.
3functionFunctionThe primary physiological role, stated causally rather than as a label.
4blood_supplyBlood supplyNames or accurately describes the supplying vessels.
5tissueTissue and microscopic structureThe characteristic tissue type and why it suits the function.
6clinicalClinical relevanceAt least one condition, and why the anatomy causes it.

Each entry is {id, criterion, status, evidence, correction} where status is correct | partial | missing | incorrect, evidence is a short verbatim quote from recitation ("" when the status is missing), and correction is non-empty for every status except correct.

The normalization rules to reproduce

  1. Strip code fences and any prose around the object; keep the outermost {…}.
  2. verdict not in the allowed set → clamp (fall back to the band implied by score).
  3. score → integer, clamped to 0–100.
  4. rubric → re-ordered to location, structure, function, blood_supply, tissue, clinical, and back-filled: a criterion the model omitted becomes status:"missing" with an empty evidence, never an absent entry. Result: always six rows, never a short list. Unknown status values are clamped to missing.
  5. misconceptions, missed, strengths, next_steps, flashcards → default to [] when absent or the wrong type. The app prints "None" under the heading rather than an empty list.
  6. If the whole parse fails, retry the run once with the same body plus a retry_note describing what was wrong — that is exactly what the app does.
# Unwrap the envelope, then the output string, then normalize with jq.
IDS='["location","structure","function","blood_supply","tissue","clinical"]'

echo "$JOB" | jq -r '.data.output.output // .data.output' \
  | sed -e 's/^```json//' -e 's/^```//' -e 's/```$//' \
  | jq --argjson ids "$IDS" '
      . as $s
      | {
          verdict: (if (["mastered","developing","needs_review"] | index($s.verdict))
                    then $s.verdict
                    elif ($s.score // 0) >= 80 then "mastered"
                    elif ($s.score // 0) >= 50 then "developing"
                    else "needs_review" end),
          score: ([[($s.score // 0) | floor, 0] | max, 100] | min),
          headline: ($s.headline // ""),
          rubric: [ $ids[] as $id
                    | (($s.rubric // []) | map(select(.id == $id)) | first)
                      // {id: $id, criterion: $id, status: "missing", evidence: "", correction: ""}
                    | .status = (if (["correct","partial","missing","incorrect"] | index(.status))
                                 then .status else "missing" end) ],
          misconceptions: ($s.misconceptions // []),
          missed: ($s.missed // []),
          strengths: ($s.strengths // []),
          next_steps: ($s.next_steps // []),
          flashcards: ($s.flashcards // [])
        }'
IDS = ["location", "structure", "function", "blood_supply", "tissue", "clinical"]
LABELS = {"location": "Location in the body", "structure": "Structure and size",
          "function": "Function", "blood_supply": "Blood supply",
          "tissue": "Tissue and microscopic structure", "clinical": "Clinical relevance"}
VERDICTS = ("mastered", "developing", "needs_review")
STATUSES = ("correct", "partial", "missing", "incorrect")

def parse_study(raw):
    # 1 — unwrap {"output": …} and the JSON-string payload
    if isinstance(raw, dict) and "output" in raw:
        raw = raw["output"]
    if isinstance(raw, str):
        text = raw.strip()
        if text.startswith("```"):
            text = text.split("\n", 1)[1].rsplit("```", 1)[0]
        start, end = text.find("{"), text.rfind("}")
        raw = json.loads(text[start:end + 1])

    # 2 — clamp score, then verdict
    try:
        score = max(0, min(100, int(float(raw.get("score", 0)))))
    except (TypeError, ValueError):
        score = 0
    verdict = raw.get("verdict")
    if verdict not in VERDICTS:
        verdict = "mastered" if score >= 80 else "developing" if score >= 50 else "needs_review"

    # 3 — exactly six rubric rows, in order, back-filled
    by_id = {r.get("id"): r for r in raw.get("rubric") or [] if isinstance(r, dict)}
    rubric = []
    for cid in IDS:
        row = by_id.get(cid) or {}
        status = row.get("status") if row.get("status") in STATUSES else "missing"
        rubric.append({
            "id": cid,
            "criterion": row.get("criterion") or LABELS[cid],
            "status": status,
            "evidence": "" if status == "missing" else (row.get("evidence") or ""),
            "correction": row.get("correction") or "",
        })

    # 4 — arrays default to []
    def arr(key):
        v = raw.get(key)
        return v if isinstance(v, list) else []

    return {"verdict": verdict, "score": score, "headline": raw.get("headline") or "",
            "rubric": rubric, "misconceptions": arr("misconceptions"),
            "missed": arr("missed"), "strengths": arr("strengths"),
            "next_steps": arr("next_steps"), "flashcards": arr("flashcards")}
const IDS = ["location", "structure", "function", "blood_supply", "tissue", "clinical"];
const LABELS = {
  location: "Location in the body", structure: "Structure and size",
  function: "Function", blood_supply: "Blood supply",
  tissue: "Tissue and microscopic structure", clinical: "Clinical relevance",
};
const VERDICTS = ["mastered", "developing", "needs_review"];
const STATUSES = ["correct", "partial", "missing", "incorrect"];

function parseStudy(rawInput) {
  // 1 — unwrap {output: …} and the JSON-string payload
  let raw = rawInput?.output ?? rawInput;
  if (typeof raw === "string") {
    let text = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
    raw = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
  }

  // 2 — clamp score, then verdict
  const score = Math.max(0, Math.min(100, Math.trunc(Number(raw.score) || 0)));
  const verdict = VERDICTS.includes(raw.verdict)
    ? raw.verdict
    : score >= 80 ? "mastered" : score >= 50 ? "developing" : "needs_review";

  // 3 — exactly six rubric rows, in order, back-filled
  const byId = new Map((Array.isArray(raw.rubric) ? raw.rubric : []).map((r) => [r?.id, r]));
  const rubric = IDS.map((id) => {
    const row = byId.get(id) ?? {};
    const status = STATUSES.includes(row.status) ? row.status : "missing";
    return {
      id,
      criterion: row.criterion || LABELS[id],
      status,
      evidence: status === "missing" ? "" : (row.evidence ?? ""),
      correction: row.correction ?? "",
    };
  });

  // 4 — arrays default to []
  const arr = (k) => (Array.isArray(raw[k]) ? raw[k] : []);

  return {
    verdict, score, headline: raw.headline ?? "", rubric,
    misconceptions: arr("misconceptions"), missed: arr("missed"),
    strengths: arr("strengths"), next_steps: arr("next_steps"),
    flashcards: arr("flashcards"),
  };
}
var criterionIDs = []string{"location", "structure", "function", "blood_supply", "tissue", "clinical"}
var criterionLabels = map[string]string{
	"location": "Location in the body", "structure": "Structure and size",
	"function": "Function", "blood_supply": "Blood supply",
	"tissue": "Tissue and microscopic structure", "clinical": "Clinical relevance",
}

type RubricRow struct {
	ID         string `json:"id"`
	Criterion  string `json:"criterion"`
	Status     string `json:"status"`
	Evidence   string `json:"evidence"`
	Correction string `json:"correction"`
}

type Study struct {
	Verdict        string              `json:"verdict"`
	Score          int                 `json:"score"`
	Headline       string              `json:"headline"`
	Rubric         []RubricRow         `json:"rubric"`
	Misconceptions []map[string]string `json:"misconceptions"`
	Missed         []string            `json:"missed"`
	Strengths      []string            `json:"strengths"`
	NextSteps      []string            `json:"next_steps"`
	Flashcards     []map[string]string `json:"flashcards"`
}

func parseStudy(raw string) (*Study, error) {
	// 1 — strip fences, keep the outermost object
	text := strings.TrimSpace(raw)
	text = strings.TrimPrefix(strings.TrimPrefix(text, "```json"), "```")
	text = strings.TrimSuffix(strings.TrimSpace(text), "```")
	if i, j := strings.Index(text, "{"), strings.LastIndex(text, "}"); i >= 0 && j > i {
		text = text[i : j+1]
	}
	var s Study
	if err := json.Unmarshal([]byte(text), &s); err != nil {
		return nil, err
	}

	// 2 — clamp score, then verdict
	if s.Score < 0 {
		s.Score = 0
	} else if s.Score > 100 {
		s.Score = 100
	}
	switch s.Verdict {
	case "mastered", "developing", "needs_review":
	default:
		if s.Score >= 80 {
			s.Verdict = "mastered"
		} else if s.Score >= 50 {
			s.Verdict = "developing"
		} else {
			s.Verdict = "needs_review"
		}
	}

	// 3 — exactly six rows, in order, back-filled
	byID := map[string]RubricRow{}
	for _, r := range s.Rubric {
		byID[r.ID] = r
	}
	rows := make([]RubricRow, 0, len(criterionIDs))
	for _, id := range criterionIDs {
		row := byID[id]
		row.ID = id
		switch row.Status {
		case "correct", "partial", "incorrect":
		default:
			row.Status = "missing"
			row.Evidence = ""
		}
		if row.Criterion == "" {
			row.Criterion = criterionLabels[id]
		}
		rows = append(rows, row)
	}
	s.Rubric = rows

	// 4 — nil slices become empty slices
	if s.Missed == nil {
		s.Missed = []string{}
	}
	if s.Strengths == nil {
		s.Strengths = []string{}
	}
	if s.NextSteps == nil {
		s.NextSteps = []string{}
	}
	if s.Misconceptions == nil {
		s.Misconceptions = []map[string]string{}
	}
	if s.Flashcards == nil {
		s.Flashcards = []map[string]string{}
	}
	return &s, nil
}
// Sketch with your JSON library of choice (Jackson shown conceptually).
static final List<String> IDS = List.of(
    "location", "structure", "function", "blood_supply", "tissue", "clinical");
static final Map<String, String> LABELS = Map.of(
    "location", "Location in the body",
    "structure", "Structure and size",
    "function", "Function",
    "blood_supply", "Blood supply",
    "tissue", "Tissue and microscopic structure",
    "clinical", "Clinical relevance");
static final Set<String> VERDICTS = Set.of("mastered", "developing", "needs_review");
static final Set<String> STATUSES = Set.of("correct", "partial", "missing", "incorrect");

static ObjectNode parseStudy(String rawOutput) throws Exception {
    // 1 — strip fences, keep the outermost {…}
    String text = rawOutput.strip();
    if (text.startsWith("```")) {
        text = text.substring(text.indexOf('\n') + 1);
        int fence = text.lastIndexOf("```");
        if (fence >= 0) text = text.substring(0, fence);
    }
    text = text.substring(text.indexOf('{'), text.lastIndexOf('}') + 1);
    ObjectNode s = (ObjectNode) MAPPER.readTree(text);

    // 2 — clamp score, then verdict
    int score = Math.max(0, Math.min(100, s.path("score").asInt(0)));
    s.put("score", score);
    if (!VERDICTS.contains(s.path("verdict").asText())) {
        s.put("verdict", score >= 80 ? "mastered" : score >= 50 ? "developing" : "needs_review");
    }

    // 3 — exactly six rows, in order, back-filled
    Map<String, JsonNode> byId = new HashMap<>();
    s.path("rubric").forEach(r -> byId.put(r.path("id").asText(), r));
    ArrayNode rows = MAPPER.createArrayNode();
    for (String id : IDS) {
        JsonNode row = byId.get(id);
        String status = row == null ? "missing" : row.path("status").asText("missing");
        if (!STATUSES.contains(status)) status = "missing";
        ObjectNode out = MAPPER.createObjectNode()
            .put("id", id)
            .put("criterion", row == null || row.path("criterion").asText("").isEmpty()
                 ? LABELS.get(id) : row.path("criterion").asText())
            .put("status", status)
            .put("evidence", status.equals("missing") || row == null
                 ? "" : row.path("evidence").asText(""))
            .put("correction", row == null ? "" : row.path("correction").asText(""));
        rows.add(out);
    }
    s.set("rubric", rows);

    // 4 — arrays default to []
    for (String key : List.of("misconceptions", "missed", "strengths", "next_steps", "flashcards")) {
        if (!s.path(key).isArray()) s.set(key, MAPPER.createArrayNode());
    }
    return s;
}
IDS = %w[location structure function blood_supply tissue clinical].freeze
LABELS = {
  "location" => "Location in the body", "structure" => "Structure and size",
  "function" => "Function", "blood_supply" => "Blood supply",
  "tissue" => "Tissue and microscopic structure", "clinical" => "Clinical relevance",
}.freeze
VERDICTS = %w[mastered developing needs_review].freeze
STATUSES = %w[correct partial missing incorrect].freeze

def parse_study(raw)
  # 1 — unwrap {"output" => …} and the JSON-string payload
  raw = raw["output"] if raw.is_a?(Hash) && raw.key?("output")
  if raw.is_a?(String)
    text = raw.strip.sub(/\A```(?:json)?\s*/i, "").sub(/```\s*\z/, "")
    raw = JSON.parse(text[text.index("{")..text.rindex("}")])
  end

  # 2 — clamp score, then verdict
  score = raw["score"].to_i.clamp(0, 100)
  verdict = VERDICTS.include?(raw["verdict"]) ? raw["verdict"]
            : score >= 80 ? "mastered" : score >= 50 ? "developing" : "needs_review"

  # 3 — exactly six rows, in order, back-filled
  by_id = Array(raw["rubric"]).select { |r| r.is_a?(Hash) }.to_h { |r| [r["id"], r] }
  rubric = IDS.map do |id|
    row = by_id[id] || {}
    status = STATUSES.include?(row["status"]) ? row["status"] : "missing"
    {
      "id" => id,
      "criterion" => (row["criterion"].to_s.empty? ? LABELS[id] : row["criterion"]),
      "status" => status,
      "evidence" => (status == "missing" ? "" : row["evidence"].to_s),
      "correction" => row["correction"].to_s,
    }
  end

  # 4 — arrays default to []
  arrays = %w[misconceptions missed strengths next_steps flashcards]
           .to_h { |k| [k, raw[k].is_a?(Array) ? raw[k] : []] }

  { "verdict" => verdict, "score" => score, "headline" => raw["headline"].to_s,
    "rubric" => rubric }.merge(arrays)
end
const IDS = ["location", "structure", "function", "blood_supply", "tissue", "clinical"];
const LABELS = [
    "location" => "Location in the body", "structure" => "Structure and size",
    "function" => "Function", "blood_supply" => "Blood supply",
    "tissue" => "Tissue and microscopic structure", "clinical" => "Clinical relevance",
];
const VERDICTS = ["mastered", "developing", "needs_review"];
const STATUSES = ["correct", "partial", "missing", "incorrect"];

function parse_study(mixed $raw): array {
    // 1 — unwrap ["output" => …] and the JSON-string payload
    if (is_array($raw) && array_key_exists("output", $raw)) $raw = $raw["output"];
    if (is_string($raw)) {
        $text = preg_replace('/^```(?:json)?\s*/i', "", trim($raw));
        $text = preg_replace('/```\s*$/', "", $text);
        $i = strpos($text, "{");
        $j = strrpos($text, "}");
        $raw = json_decode(substr($text, $i, $j - $i + 1), true, 512, JSON_THROW_ON_ERROR);
    }

    // 2 — clamp score, then verdict
    $score = max(0, min(100, (int) ($raw["score"] ?? 0)));
    $verdict = in_array($raw["verdict"] ?? "", VERDICTS, true)
        ? $raw["verdict"]
        : ($score >= 80 ? "mastered" : ($score >= 50 ? "developing" : "needs_review"));

    // 3 — exactly six rows, in order, back-filled
    $byId = [];
    foreach ((array) ($raw["rubric"] ?? []) as $row) {
        if (is_array($row) && isset($row["id"])) $byId[$row["id"]] = $row;
    }
    $rubric = [];
    foreach (IDS as $id) {
        $row = $byId[$id] ?? [];
        $status = in_array($row["status"] ?? "", STATUSES, true) ? $row["status"] : "missing";
        $rubric[] = [
            "id"         => $id,
            "criterion"  => $row["criterion"] ?? LABELS[$id],
            "status"     => $status,
            "evidence"   => $status === "missing" ? "" : ($row["evidence"] ?? ""),
            "correction" => $row["correction"] ?? "",
        ];
    }

    // 4 — arrays default to []
    $out = ["verdict" => $verdict, "score" => $score,
            "headline" => $raw["headline"] ?? "", "rubric" => $rubric];
    foreach (["misconceptions", "missed", "strengths", "next_steps", "flashcards"] as $k) {
        $out[$k] = is_array($raw[$k] ?? null) ? $raw[$k] : [];
    }
    return $out;
}
static readonly string[] Ids =
    { "location", "structure", "function", "blood_supply", "tissue", "clinical" };
static readonly Dictionary<string, string> Labels = new() {
    ["location"] = "Location in the body", ["structure"] = "Structure and size",
    ["function"] = "Function", ["blood_supply"] = "Blood supply",
    ["tissue"] = "Tissue and microscopic structure", ["clinical"] = "Clinical relevance",
};
static readonly string[] Verdicts = { "mastered", "developing", "needs_review" };
static readonly string[] Statuses = { "correct", "partial", "missing", "incorrect" };

record RubricRow(string Id, string Criterion, string Status, string Evidence, string Correction);

static (string Verdict, int Score, string Headline, List<RubricRow> Rubric) ParseStudy(string rawOutput)
{
    // 1 — strip fences, keep the outermost {…}
    var text = rawOutput.Trim();
    if (text.StartsWith("```"))
    {
        text = text[(text.IndexOf('\n') + 1)..];
        var fence = text.LastIndexOf("```", StringComparison.Ordinal);
        if (fence >= 0) text = text[..fence];
    }
    text = text[text.IndexOf('{')..(text.LastIndexOf('}') + 1)];
    var root = JsonDocument.Parse(text).RootElement;

    // 2 — clamp score, then verdict
    var score = root.TryGetProperty("score", out var sc) && sc.TryGetInt32(out var n)
        ? Math.Clamp(n, 0, 100) : 0;
    var verdict = root.TryGetProperty("verdict", out var v) && Verdicts.Contains(v.GetString())
        ? v.GetString()!
        : score >= 80 ? "mastered" : score >= 50 ? "developing" : "needs_review";

    // 3 — exactly six rows, in order, back-filled
    var byId = new Dictionary<string, JsonElement>();
    if (root.TryGetProperty("rubric", out var arr) && arr.ValueKind == JsonValueKind.Array)
        foreach (var r in arr.EnumerateArray())
            if (r.TryGetProperty("id", out var idEl) && idEl.GetString() is string id)
                byId[id] = r;

    var rubric = Ids.Select(id =>
    {
        var has = byId.TryGetValue(id, out var row);
        string Str(string key) => has && row.TryGetProperty(key, out var p)
            ? p.GetString() ?? "" : "";
        var status = Statuses.Contains(Str("status")) ? Str("status") : "missing";
        var criterion = Str("criterion") is { Length: > 0 } c ? c : Labels[id];
        return new RubricRow(id, criterion, status,
            status == "missing" ? "" : Str("evidence"), Str("correction"));
    }).ToList();

    // 4 — misconceptions / missed / strengths / next_steps / flashcards:
    //     read with ValueKind == JsonValueKind.Array checks and fall back to
    //     an empty list, so a missing section renders as "None".
    var headline = root.TryGetProperty("headline", out var h) ? h.GetString() ?? "" : "";
    return (verdict, score, headline, rubric);
}

Because the loop is study, then recite again, a second run on the same organ is worth diffing: the app keeps the last 20 records under the history key ({id, organ, mode, level, score, verdict, when, recitation, result}, newest first) and renders a score delta against that organ's most recent previous attempt. Rebuild the same thing in your own client by comparing score and per-criterion status across runs — keeping organ_brief, mode and level identical is what keeps the delta meaningful.

This is a study aid, not medical advice. The grader marks only the anatomy in a recitation and will never read it as a description of your own symptoms — do not build a diagnostic flow on top of it.