Question Ladder - API

A tutor that asks before it tells, from your own code.

Run Socratic tutor turns from your own code

Question Ladder is a patient Socratic tutor: given one topic, it asks a diagnostic question, then probes, hints and checks one turn at a time so the learner does the thinking. Every answer is assessed against a small concept map, and the support level fades down as the learner gets things right and steps up when they are stuck.

Each tutor turn is one call to the SkillSafe App API: a flat JSON object in over HTTPS, JSON Lines out. Pick a language once and every code sample on the page follows.

Basics

Base URL https://api.skillsafe.ai/v1/app-api. Every call except POST /guest sends Authorization: Bearer <token>; bodies are JSON with Content-Type: application/json. The token is bound to this app, so there is no app or slug header: the slug appears only in the POST /guest body.

Responses use one envelope: {"ok":true,"data":{...}} on success and {"ok":false,"error":{"code","message","details"}} on failure. /estimate, /run and /run-stream take the input object itself as the body. Never wrap it as {"input":{...}}: the server only warns unknown field 'input', and the run is still charged.

Credits: 1 credit = $0.0001. /estimate is free and creates no job; /run and /run-stream hold up to hold_credits and charge charged_credits when the job finishes.

StatusTypical error.codeMeaning
400validation_errorThe body failed validation (a field of the wrong type, an object where a scalar belongs); details names the field.
401unauthorizedInvalid or expired app session. Get a new token (step 1).
402payment_requiredInsufficient credits: the balance is below min_credits for this run. Top up, or sign in for a personal token.
403forbiddenThe token may not do this. A guest token can run metered turns only while this app sponsors guests; when it does not, sign in for a personal token.
404not_foundUnknown app (a misspelt slug in POST /guest), or an unknown job id, or a job that belongs to another token.
409conflictThe Idempotency-Key was already used with a different body.
429rate_limitedRate limit. Back off and retry with the same Idempotency-Key.
5xx-Transient platform error. Retry with backoff and the same Idempotency-Key.

Branch on the HTTP status and error.code, and show error.message to a person; the exact codes and wording are the platform's and can gain detail over time.

Browsers enforce CORS for this API, so run the samples from a server, script or terminal, not from another website's frontend.

The task field first

task decides which tutor turn comes back, and each task has exactly one turn type. The tutor never blends two. If task is missing or not one of the five, it picks the closest one from the fields present and adds "routed":"fallback" to the turn line, so always send it. Each task has a complete worked example.

taskWhenTurn typeLines, in order
openThe first tutor turn. Diagnoses before teaching with one question about the most basic concept, often a prediction that exposes a common misconception.questionturn, hint x2-3 (levels 1, 2, 3), scaffold (at hint_level, move hold), parent in homework mode, end. No assess: nothing was answered yet.
answerThe learner answered the last question.questionturn (one sentence of honest feedback, then the next rung as one question, or the contradiction probe after a misconception), assess x1 (normally focus_concept), misconception if one showed, correction x0-2 in language mode, hint x2-3 for the NEW question, scaffold (fading rule), parent in homework mode, end.
stuckThe learner pressed "I'm stuck"; answer may be empty.hintturn (one support level up, a partial or small worked step, then an easier, smaller question), assess x1 with verdict stuck for focus_concept, hint x1-2 (levels 1, 2) for the smaller question, scaffold (move up, or hold at 5), parent in homework mode, end.
checkThe learner wants a DRAFT answer checked before committing to it.checkturn (what holds up, then one question that makes the learner find the weak part; no new question, no corrected answer), assess x1 for focus_concept, misconception if one showed, correction x0-2 in language mode, scaffold (normally hold), parent in homework mode, end.
summaryThe learner ends the session.summaryturn (concept empty; a 3-5 sentence recap of what the learner worked out), recap for EVERY concept whose seen is above 0, transfer x1, practice x1, end.

The support scale behind hint_level, scaffold_level and the scaffold line: 1 Transfer (the learner teaches it back), 2 Independent, 3 Coached, 4 Guided (the tutor starts a step, the learner finishes), 5 Modelled (the tutor shows one small step). After a correct answer support moves down one level, after a stuck or repeated wrong answer it moves up one, otherwise it holds; never more than one level per turn, and it stays at or below hint_level except on stuck.

Input fields

The body is one flat JSON object of scalars: strings and numbers only. The concept map is the one structured value, and it travels JSON-encoded inside the string field concepts_json. Send every field on every turn (empty string or 0 when it does not apply), exactly as the app does.

FieldTypeNotes
taskstringopen | answer | stuck | check | summary. See the table above.
topicstringWhat is being learned, e.g. Why does Earth have seasons? The app sends up to 300 characters.
modestringconcept | passage (questions about a text, quotes copied word for word) | claim (question the reasoning and stated evidence) | language (converse in the target language, up to two correction lines) | homework (questions go to a child, plus a parent line).
learnerstringchild (8-11, a parent beside them) | teen | adult | adult-background.
passagestringThe text being read, or empty. The app keeps up to 4,000 characters: the opening and closing sentences, with the middle replaced by a marker such as [... 6 sentences (812 characters) omitted ...].
concepts_jsonstring (JSON array)A JSON-ENCODED STRING of the concept map, e.g. "[{\"id\":\"c1\",\"label\":\"axial tilt\",\"mastery\":0.35,\"seen\":2}]". These ids are the only ones that exist; mastery is 0-1 as you currently hold it, seen is how many times the concept was assessed. The app keeps up to 8 concepts.
transcriptstringThe compact dialogue window, one entry per line: T3 TUTOR question [c2]: ..., T4 LEARNER answer (confidence 4/5): .... Empty on open. See How a session works.
questionstringThe tutor's most recent turn, verbatim (the app sends up to 700 characters). Empty on open.
focus_conceptstringThe concept id that turn targeted (its concept field).
answerstringThe learner's newest answer for answer, or the draft for check (up to 1,500 characters). Empty for the other tasks.
confidencenumberThe learner's self-rating 1-5 for answer and check; 0 otherwise.
hint_levelnumber1-5: the most support the learner asked for on the dial.
scaffold_levelnumber1-5: the support level of the last turn (the last scaffold line's level; 3 to start).
hints_revealednumberHow many of the tutor's prepared hints for the current question the learner opened before answering.
turn_indexnumberThe number this tutor turn gets: the next free T number, plus one for answer and check because the learner's line takes the first. open on a fresh dialogue is 1; the first answer is 3.
clip_notestringWhat the app cut before sending (passage sentences, older turns, a long answer), in plain words; empty when nothing was cut.
retry_notestring, optionalA correction the app adds when it re-asks after a malformed reply. Leave it out normally.

How a session works

There is no server-side session. Each tutor turn is ONE stateless /run or /run-stream call, and the server keeps nothing between turns. Your client owns the dialogue and the concept map, and re-sends what the tutor needs every turn.

  1. Build the concept map (up to 8 ids c1, c2, ... with a label, mastery 0 and seen 0) and send open with turn_index 1.
  2. Show the turn line's say. Keep the hint lines hidden and reveal one at a time when the learner asks; count how many were opened for hints_revealed.
  3. When the learner answers, asks for help, wants a draft checked or ends, send the matching task with the clipped passage, the concept map with its current mastery (concepts_json), a compact transcript window, the last tutor turn as question and focus_concept, and the new turn_index.
  4. Parse the JSON Lines reply and apply each assess line to your own map: only ids that already exist in the map move (new mastery, seen + 1). An unknown id changes nothing, and a suggest line is shown to the learner as a suggestion; neither is ever added to the map automatically. Store the last scaffold level as the next scaffold_level.
  5. Append the learner's line and the tutor's turn to your dialogue, then go to step 3.

The transcript window is the opening tutor question (it anchors the goal) plus the last six entries, each clipped to about 520 characters. Older entries are replaced by one marker, so the tutor knows something was cut and reads the progress from the mastery in concepts_json instead. This is the window the app sends for the answer turn numbered 15:

T1 TUTOR question [c4]: Before any explanation, I want your own idea first. Why do you think it is warmer in summer than in winter where you live - what is different about Earth in those two seasons?
[... turns T2-T7 omitted (6 entries); mastery so far is in concepts_json ...]
T8 LEARNER check (confidence 3/5): When the flashlight is slanted the patch gets bigger, so the same light is spread over more table and each bit gets less. In summer the days are also longer, so there is more time to heat up.
T9 TUTOR check [c2]: The spreading part holds up: the same light over a bigger patch means less warmth for each bit of ground. Before you commit, test the link to the seasons: in your draft, what decides whether sunlight reaches your part of Earth straight on or at a slant in a given month?
T10 LEARNER answer (confidence 4/5): In December the southern half leans toward the Sun, so it gets the straight-on light.
T11 TUTOR question [c1]: Right. Now picture the tilt itself: does the direction Earth's axis points change during the year, or stay the same?
T12 LEARNER stuck: (no answer - asked for help)
T13 TUTOR hint [c1]: Let's take one step. The axis keeps pointing the same way all year, like a spinning top that keeps its lean. Smaller question: if the lean never changes but Earth moves around the Sun, which half leans toward the Sun in June?

The app does not copy the reply's mastery blindly: it averages it with its own step from the verdict (correct +0.25, partial +0.1, misconception -0.15, stuck -0.05, gains shrunk when hints were opened). A simple client can take the reply's mastery as it is; either way, what you send next in concepts_json is the tutor's starting point.

The platform also offers /sessions endpoints for multi-turn chats. Question Ladder does not use them: a session turn cannot be priced with the free /estimate first, and it would keep a second stored copy of the conversation the learner did not ask for. When someone is signed in, the app's own record of the dialogue lives in its dialogues collection (with a local copy in the browser); API callers keep the dialogue wherever they like.

Send an Idempotency-Key header on every turn, so a network retry cannot start a second, double-charged run. Put the task and the turn index in it so two turns never collide. The app uses question-ladder:<task>:t<turn_index>:<hash>:a<attempt>, where the hash is a 32-bit FNV-1a of the JSON body (8 hex digits) and the attempt number goes up only for a deliberate re-ask, such as the one reformat retry with a retry_note. Reusing a key with a different body is a 409.

Reply contract

The finished job's text is at output.output. It is JSON Lines: one complete JSON object per line, each with a k kind, no prose and no code fences. The first line is always turn and the last is always {"k":"end"}. Split on newlines, parse each line and ignore anything that does not parse (step 4 shows this in every language). If the end line is missing, or the job says truncated, the reply was cut short; if there is no turn line at all, re-ask once with a retry_note and a new attempt number.

kFieldsWhere
turntask (echoes the request), type: question | hint | check | summary, concept (a map id; empty on a summary), say (what the tutor says to the learner), optional routed: "fallback".Always first, exactly once.
assessconcept, verdict: correct | partial | misconception | stuck, mastery 0-1 after this answer, evidence (the learner's words, copied), note (one sentence).answer, stuck, check.
misconceptionconcept, belief (the faulty model, one sentence), probe (the contradiction question).answer, check, when one showed.
hintlevel 1-3 (1 a nudge, 2 a stronger cue, 3 nearly a worked step), text. Never the answer itself; keep them hidden until asked.open, answer (x2-3), stuck (x1-2).
scaffoldlevel 1-5, move: down | up | hold, why (one short sentence).Every task except summary.
correctionoriginal (the learner's words), corrected, note.Language mode, answer and check, 0-2.
parenttext: one or two sentences telling the parent how to help without giving the answer.Homework mode, every task except summary.
suggestconcept (the next free id, e.g. c6), concept_label (2-4 words), why.Optional, at most one per turn, only when the learning needs an idea the map lacks. Show it; never add it automatically.
recapconcept, mastery (the tutor's estimate from the whole dialogue), evidence (one sentence), next (one thing to practise).summary, one per concept with seen above 0.
transfertext: one new question in a different context, not yet answered.summary.
practicetext: one short practice suggestion.summary.
endNo other fields.Always last.

Every concept on turn, assess, misconception and recap lines is meant to be an id from your concepts_json; check it anyway, and treat any other id as unknown. The replies are AI-generated tutoring: show them as the tutor's words, not as verified facts.

Step 1 - A tiny client, and a token

POST /guest

Start with a short helper that adds the auth header, sends JSON and unwraps the envelope; every later step reuses it. For the token, open the token page, sign in with SkillSafe and copy the token (or the shell export line). A personal token bills metered runs to your account. Scripts with no browser at all can mint a guest token with POST /guest and the body {"slug":"question-ladder"}, sent with no auth header; it answers 201 with {token, guest_id, expires_at}. There is no app header on any later call.

export API="https://api.skillsafe.ai/v1/app-api"
export SKILLSAFE_TOKEN="YOUR_TOKEN"   # "Copy shell export" on /tokens.html sets this for you

# No browser at all? Mint a guest token (this one call sends no Authorization header):
SKILLSAFE_TOKEN=$(curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"question-ladder"}' | jq -r '.data.token')
# 201 -> {"ok":true,"data":{"token":"...","guest_id":"...","expires_at":"..."}}

# Every later call looks like:
#   curl -s "$API/..." -H "Authorization: Bearer $SKILLSAFE_TOKEN" [-H "Content-Type: application/json" -d @body.json]
# and jq reads fields out of the {"ok":true,"data":{...}} envelope.
import json, os, time, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN")  # copy it from tokens.html

def api(method, path, body=None, headers=None, auth=True):
    """Send JSON (a dict, or an already-encoded string), unwrap the envelope."""
    h = {"Content-Type": "application/json", **(headers or {})}
    if auth and TOKEN:
        h["Authorization"] = f"Bearer {TOKEN}"
    data = body if isinstance(body, str) or body is None else json.dumps(body)
    res = requests.request(method, API + path, data=data, headers=h, timeout=180)
    payload = res.json()
    if not res.ok or payload.get("ok") is False:
        err = payload.get("error", {})
        raise RuntimeError(f'{res.status_code} {err.get("code")}: {err.get("message")}')
    return payload["data"]

# No browser at all? Mint a guest token (no Authorization header on this call).
if not TOKEN:
    TOKEN = api("POST", "/guest", {"slug": "question-ladder"}, auth=False)["token"]
// Node 18+ (built-in fetch), run as an ES module (top-level await)
const API = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // paste it from tokens.html, or load it from your secret store

async function api(method, path, body, extraHeaders = {}, auth = true) {
  const headers = { "Content-Type": "application/json", ...extraHeaders };
  if (auth) headers.Authorization = `Bearer ${TOKEN}`;
  const res = await fetch(API + path, {
    method,
    headers,
    body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body),
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok || json.ok === false) {
    const e = json.error ?? {};
    throw Object.assign(new Error(`${res.status} ${e.code}: ${e.message ?? res.statusText}`),
      { status: res.status, code: e.code, details: e.details });
  }
  return json.data;
}

// No browser at all? Mint a guest token (no Authorization header on this call).
if (TOKEN === "YOUR_TOKEN") {
  ({ token: TOKEN } = await api("POST", "/guest", { slug: "question-ladder" }, {}, false));
}
package main

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

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

var token = os.Getenv("SKILLSAFE_TOKEN") // copy it from tokens.html

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

// call sends body (any value, or json.RawMessage for pre-encoded bytes) and
// decodes the envelope's data into out.
func call(method, path string, body any, headers map[string]string, 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("Content-Type", "application/json")
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	for k, v := range headers {
		req.Header.Set(k, v)
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var env envelope
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 || env.Error != nil {
		if env.Error != nil {
			return fmt.Errorf("%d %s: %s", res.StatusCode, env.Error.Code, env.Error.Message)
		}
		return fmt.Errorf("HTTP %d", res.StatusCode)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}

// No browser at all? Mint a guest token (sent while token is still empty, so no auth header).
func ensureToken() {
	if token != "" {
		return
	}
	var g struct{ Token string `json:"token"` }
	if err := call("POST", "/guest", map[string]string{"slug": "question-ladder"}, nil, &g); err != nil {
		log.Fatal(err)
	}
	token = g.Token
}
// Java 17+, with Jackson (com.fasterxml.jackson.core:jackson-databind) for JSON.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

public class QuestionLadder {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final ObjectMapper JSON = new ObjectMapper();
    static final HttpClient HTTP = HttpClient.newHttpClient();
    static String token = System.getenv("SKILLSAFE_TOKEN"); // copy it from tokens.html

    /** body: any Jackson-serialisable value, or an already-encoded JSON String. */
    static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
        var b = HttpRequest.newBuilder(URI.create(API + path))
            .header("Content-Type", "application/json")
            .method(method, body == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(body instanceof String s ? s : JSON.writeValueAsString(body)));
        if (token != null) b.header("Authorization", "Bearer " + token);
        headers.forEach(b::header);
        var res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
        JsonNode env = JSON.readTree(res.body());
        if (res.statusCode() >= 400 || !env.path("ok").asBoolean(true))
            throw new RuntimeException(res.statusCode() + " " + env.path("error").path("code").asText()
                + ": " + env.path("error").path("message").asText());
        return env.get("data");
    }

    // No browser at all? Mint a guest token (token is null, so no auth header is sent).
    static void ensureToken() throws Exception {
        if (token == null) token = api("POST", "/guest", Map.of("slug", "question-ladder"), Map.of()).get("token").asText();
    }

    // Steps 2-5 below go in main(); the static helpers they define sit beside api().
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
$token = ENV["SKILLSAFE_TOKEN"] # copy it from tokens.html

# body: a Hash, or an already-encoded JSON String
def api(method, path, body = nil, headers = {})
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Content-Type"] = "application/json"
  req["Authorization"] = "Bearer #{$token}" if $token
  headers.each { |k, v| req[k] = v }
  req.body = body.is_a?(String) ? body : body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 180) { |h| h.request(req) }
  payload = (JSON.parse(res.body) rescue {})
  unless res.is_a?(Net::HTTPSuccess) && payload["ok"] != false
    err = payload["error"] || {}
    raise "#{res.code} #{err["code"]}: #{err["message"] || res.message}"
  end
  payload["data"]
end

# No browser at all? Mint a guest token ($token is nil, so no auth header is sent).
$token ||= api("POST", "/guest", { slug: "question-ladder" })["token"]
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: null; // copy it from tokens.html

// $body: an array, or an already-encoded JSON string
function api(string $method, string $path, $body = null, array $headers = []): array {
    global $TOKEN;
    $h = ["Content-Type: application/json"];
    if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
    foreach ($headers as $k => $v) $h[] = "$k: $v";
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => $h,
        CURLOPT_TIMEOUT        => 180,
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS,
            is_string($body) ? $body : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
    }
    $payload = json_decode(curl_exec($ch), true) ?? [];
    $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400 || ($payload["ok"] ?? true) === false) {
        $e = $payload["error"] ?? [];
        throw new Exception("$status " . ($e["code"] ?? "") . ": " . ($e["message"] ?? "HTTP error"));
    }
    return $payload["data"];
}

// No browser at all? Mint a guest token ($TOKEN is null, so no auth header is sent).
$TOKEN = $TOKEN ?: api("POST", "/guest", ["slug" => "question-ladder"])["token"];
// .NET 8+, Program.cs. Top-level statements come first; the helper class goes at the end of the file.
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

// No browser at all? Mint a guest token (Token is null, so no auth header is sent).
QuestionLadder.Token ??= (await QuestionLadder.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "question-ladder" })).GetProperty("token").GetString();

// ... steps 2-5 go here ...

static class QuestionLadder
{
    public const string Api = "https://api.skillsafe.ai/v1/app-api";
    public static readonly HttpClient Http = new() { Timeout = TimeSpan.FromMinutes(5) };
    public static string? Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN"); // copy it from tokens.html

    // body: any serialisable object, or an already-encoded JSON string
    public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null,
        Dictionary<string, string>? headers = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (Token != null) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
        foreach (var (k, v) in headers ?? new()) req.Headers.Add(k, v);
        if (body != null)
            req.Content = new StringContent(body as string ?? 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)
        {
            var e = json.GetProperty("error");
            throw new Exception($"{(int)res.StatusCode} {e.GetProperty("code")}: {e.GetProperty("message")}");
        }
        return json.GetProperty("data");
    }
}

Treat a token like a password: it can spend its credits. A guest token only runs metered turns while the app sponsors guests (a 403 otherwise); a signed-in token always can.

Step 2 - Who am I, and what is my balance?

GET /me

Returns subject_type ("user" for a personal token, "guest" for a guest token), subject_id and credits. Branch on subject_type. A 401 here means the token is stale.

curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq '.data'
# {"subject_type":"guest","subject_id":"gst_...","credits":2000}
me = api("GET", "/me")
print(me["subject_type"], me["subject_id"], me["credits"], "credits",
      f'(${me["credits"] * 0.0001:.2f})')
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, `${me.credits} credits ($${(me.credits * 0.0001).toFixed(2)})`);
// in main():
ensureToken()
var me struct {
	SubjectType string `json:"subject_type"` // "user" or "guest"
	SubjectID   string `json:"subject_id"`
	Credits     int64  `json:"credits"`
}
if err := call("GET", "/me", nil, nil, &me); err != nil {
	log.Fatal(err)
}
fmt.Printf("%s %s: %d credits ($%.2f)\n", me.SubjectType, me.SubjectID, me.Credits, float64(me.Credits)*0.0001)
ensureToken();
JsonNode me = api("GET", "/me", null, Map.of());
System.out.printf("%s %s: %d credits%n",
    me.get("subject_type").asText(), me.get("subject_id").asText(), me.get("credits").asLong());
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 QuestionLadder.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("subject_id")}: {me.GetProperty("credits")} credits");

Step 3 - Price a turn before running it

POST /estimate

Send exactly the body you would send to /run. The answer is {hold_credits, min_credits, model, model_alias, markup_bps, input_checked, warnings[]}: hold_credits is the most a run can cost, min_credits the balance needed to start one, and warnings lists anything the server did not like about the input (an unknown field, for instance). It is free: nothing is charged and no job is created, so you can price every turn before the learner commits to it. The sample builds the seasons concept map and the open turn.

# The concept map lives on YOUR side: ids, labels, mastery 0-1, and how often each was assessed.
cat > concepts.json <<'JSON'
[
  {"id": "c1", "label": "axial tilt", "mastery": 0, "seen": 0},
  {"id": "c2", "label": "direct vs slanted sunlight", "mastery": 0, "seen": 0},
  {"id": "c3", "label": "day length", "mastery": 0, "seen": 0},
  {"id": "c4", "label": "distance from the Sun", "mastery": 0, "seen": 0},
  {"id": "c5", "label": "opposite hemispheres", "mastery": 0, "seen": 0}
]
JSON

# The first tutor turn. concepts_json must be a STRING, so jq's tojson encodes the array into one.
jq -n --slurpfile c concepts.json '{
  task: "open", topic: "Why does Earth have seasons?", mode: "concept", learner: "teen",
  passage: "", concepts_json: ($c[0] | tojson), transcript: "",
  question: "", focus_concept: "", answer: "", confidence: 0,
  hint_level: 3, scaffold_level: 3, hints_revealed: 0, turn_index: 1, clip_note: ""
}' > open.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -d @open.json | jq '.data | {hold_credits, min_credits, model_alias, input_checked, warnings}'
# The concept map lives on YOUR side: ids, labels, mastery 0-1, and how often each was assessed.
labels = ["axial tilt", "direct vs slanted sunlight", "day length",
          "distance from the Sun", "opposite hemispheres"]
concepts = [{"id": f"c{i + 1}", "label": l, "mastery": 0, "seen": 0} for i, l in enumerate(labels)]

def compact(obj):
    return json.dumps(obj, separators=(",", ":"), ensure_ascii=False)

payload = {
    "task": "open",
    "topic": "Why does Earth have seasons?",
    "mode": "concept",
    "learner": "teen",
    "passage": "",
    "concepts_json": compact(concepts),  # a STRING, not an array
    "transcript": "",
    "question": "",
    "focus_concept": "",
    "answer": "",
    "confidence": 0,
    "hint_level": 3,
    "scaffold_level": 3,
    "hints_revealed": 0,
    "turn_index": 1,
    "clip_note": "",
}

est = api("POST", "/estimate", payload)  # the input object itself, never {"input": ...}
print(est["hold_credits"], "credits held at most;", est["min_credits"], "needed to start")
for w in est["warnings"]:
    print("warning:", w)
// The concept map lives on YOUR side: ids, labels, mastery 0-1, and how often each was assessed.
const labels = ["axial tilt", "direct vs slanted sunlight", "day length",
  "distance from the Sun", "opposite hemispheres"];
const concepts = labels.map((label, i) => ({ id: `c${i + 1}`, label, mastery: 0, seen: 0 }));

const payload = {
  task: "open",
  topic: "Why does Earth have seasons?",
  mode: "concept",
  learner: "teen",
  passage: "",
  concepts_json: JSON.stringify(concepts), // a STRING, not an array
  transcript: "",
  question: "",
  focus_concept: "",
  answer: "",
  confidence: 0,
  hint_level: 3,
  scaffold_level: 3,
  hints_revealed: 0,
  turn_index: 1,
  clip_note: "",
};

const est = await api("POST", "/estimate", payload); // the input object itself, never { input: ... }
console.log(`${est.hold_credits} credits held at most, ${est.min_credits} needed to start`);
for (const w of est.warnings) console.log("warning:", w);
// at package level: the concept map lives on YOUR side.
type Concept struct {
	ID      string  `json:"id"`
	Label   string  `json:"label"`
	Mastery float64 `json:"mastery"` // 0-1
	Seen    int     `json:"seen"`    // times assessed
}

// ... in main():
labels := []string{"axial tilt", "direct vs slanted sunlight", "day length",
	"distance from the Sun", "opposite hemispheres"}
concepts := make([]Concept, len(labels))
for i, l := range labels {
	concepts[i] = Concept{ID: fmt.Sprintf("c%d", i+1), Label: l}
}
cj, _ := json.Marshal(concepts)

payload := map[string]any{
	"task":           "open",
	"topic":          "Why does Earth have seasons?",
	"mode":           "concept",
	"learner":        "teen",
	"passage":        "",
	"concepts_json":  string(cj), // a STRING, not an array
	"transcript":     "",
	"question":       "",
	"focus_concept":  "",
	"answer":         "",
	"confidence":     0,
	"hint_level":     3,
	"scaffold_level": 3,
	"hints_revealed": 0,
	"turn_index":     1,
	"clip_note":      "",
}

var est struct {
	HoldCredits int64  `json:"hold_credits"`
	MinCredits  int64  `json:"min_credits"`
	ModelAlias  string `json:"model_alias"`
	Warnings    []any  `json:"warnings"`
}
if err := call("POST", "/estimate", payload, nil, &est); err != nil {
	log.Fatal(err)
}
fmt.Println(est.HoldCredits, "credits held at most;", est.MinCredits, "needed to start")
for _, w := range est.Warnings {
	fmt.Println("warning:", w)
}
// The concept map lives on YOUR side: ids, labels, mastery 0-1, and how often each was assessed.
var labels = List.of("axial tilt", "direct vs slanted sunlight", "day length",
    "distance from the Sun", "opposite hemispheres");
ArrayNode concepts = JSON.createArrayNode();
for (int i = 0; i < labels.size(); i++)
    concepts.addObject().put("id", "c" + (i + 1)).put("label", labels.get(i)).put("mastery", 0).put("seen", 0);

ObjectNode payload = JSON.createObjectNode()
    .put("task", "open")
    .put("topic", "Why does Earth have seasons?")
    .put("mode", "concept")
    .put("learner", "teen")
    .put("passage", "")
    .put("concepts_json", JSON.writeValueAsString(concepts)) // a STRING, not an array
    .put("transcript", "")
    .put("question", "")
    .put("focus_concept", "")
    .put("answer", "")
    .put("confidence", 0)
    .put("hint_level", 3)
    .put("scaffold_level", 3)
    .put("hints_revealed", 0)
    .put("turn_index", 1)
    .put("clip_note", "");

JsonNode est = api("POST", "/estimate", payload, Map.of());
System.out.println(est.get("hold_credits") + " credits held at most; "
    + est.get("min_credits") + " needed to start");
est.get("warnings").forEach(w -> System.out.println("warning: " + w));
# The concept map lives on YOUR side: ids, labels, mastery 0-1, and how often each was assessed.
labels = ["axial tilt", "direct vs slanted sunlight", "day length",
          "distance from the Sun", "opposite hemispheres"]
concepts = labels.each_with_index.map { |l, i| { "id" => "c#{i + 1}", "label" => l, "mastery" => 0, "seen" => 0 } }

payload = {
  "task" => "open",
  "topic" => "Why does Earth have seasons?",
  "mode" => "concept",
  "learner" => "teen",
  "passage" => "",
  "concepts_json" => concepts.to_json, # a STRING, not an array
  "transcript" => "",
  "question" => "",
  "focus_concept" => "",
  "answer" => "",
  "confidence" => 0,
  "hint_level" => 3,
  "scaffold_level" => 3,
  "hints_revealed" => 0,
  "turn_index" => 1,
  "clip_note" => ""
}

est = api("POST", "/estimate", payload)
puts "#{est["hold_credits"]} credits held at most; #{est["min_credits"]} needed to start"
est["warnings"].each { |w| puts "warning: #{w}" }
// The concept map lives on YOUR side: ids, labels, mastery 0-1, and how often each was assessed.
$labels = ["axial tilt", "direct vs slanted sunlight", "day length",
           "distance from the Sun", "opposite hemispheres"];
$concepts = [];
foreach ($labels as $i => $l) $concepts[] = ["id" => "c" . ($i + 1), "label" => $l, "mastery" => 0, "seen" => 0];

$payload = [
    "task"           => "open",
    "topic"          => "Why does Earth have seasons?",
    "mode"           => "concept",
    "learner"        => "teen",
    "passage"        => "",
    "concepts_json"  => json_encode($concepts, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), // a STRING
    "transcript"     => "",
    "question"       => "",
    "focus_concept"  => "",
    "answer"         => "",
    "confidence"     => 0,
    "hint_level"     => 3,
    "scaffold_level" => 3,
    "hints_revealed" => 0,
    "turn_index"     => 1,
    "clip_note"      => "",
];

$est = api("POST", "/estimate", $payload);
echo "{$est['hold_credits']} credits held at most; {$est['min_credits']} needed to start\n";
foreach ($est["warnings"] as $w) echo "warning: " . json_encode($w) . "\n";
// The concept map lives on YOUR side: ids, labels, mastery 0-1, and how often each was assessed.
var labels = new[] { "axial tilt", "direct vs slanted sunlight", "day length",
    "distance from the Sun", "opposite hemispheres" };
var concepts = labels.Select((l, i) => new Dictionary<string, object>
    { ["id"] = $"c{i + 1}", ["label"] = l, ["mastery"] = 0.0, ["seen"] = 0 }).ToList();

var payload = new Dictionary<string, object>
{
    ["task"] = "open",
    ["topic"] = "Why does Earth have seasons?",
    ["mode"] = "concept",
    ["learner"] = "teen",
    ["passage"] = "",
    ["concepts_json"] = JsonSerializer.Serialize(concepts), // a STRING, not an array
    ["transcript"] = "",
    ["question"] = "",
    ["focus_concept"] = "",
    ["answer"] = "",
    ["confidence"] = 0,
    ["hint_level"] = 3,
    ["scaffold_level"] = 3,
    ["hints_revealed"] = 0,
    ["turn_index"] = 1,
    ["clip_note"] = "",
};

var est = await QuestionLadder.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"{est.GetProperty("hold_credits")} credits held at most; {est.GetProperty("min_credits")} needed to start");
foreach (var w in est.GetProperty("warnings").EnumerateArray()) Console.WriteLine($"warning: {w}");

Step 4 - Run the first turn and poll the job

POST /run
GET /jobs/{job_id}

/run takes the same body plus an Idempotency-Key header, places the credit hold and returns {job_id}. Poll GET /jobs/{job_id} every second or two until status is succeeded or failed. The finished job carries the reply at output.output, plus charged_credits and truncated. The samples parse the JSON Lines, print the tutor's question, keep the hints back for later and remember the turn and scaffold lines for the next turn.

BODY=$(jq -c . open.json)
# The app hashes the body with FNV-1a; from a shell any stable hash of the exact body works.
HASH=$(printf %s "$BODY" | shasum | cut -c1-8)
KEY="question-ladder:open:t1:$HASH:a1"   # <slug>:<task>:t<turn_index>:<hash>:a<attempt>

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$BODY" | 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
echo "$JOB" | jq '.data | {status, charged_credits, truncated}'

# The reply is JSON Lines: keep every line that parses as an object with "k", drop the rest.
echo "$JOB" | jq -r '.data.output.output' \
  | jq -R -c 'fromjson? | select(type == "object" and has("k"))' > reply.jsonl

jq -r 'select(.k == "turn") | "Tutor [\(.concept)]: \(.say)"' reply.jsonl
jq -r 'select(.k == "hint") | "  hint \(.level), hidden until asked: \(.text)"' reply.jsonl
tail -n 1 reply.jsonl   # {"k":"end"} - anything else means the reply was cut short
def fnv1a(s):
    """The app's 32-bit FNV-1a (it hashes UTF-16 code units; identical for ASCII)."""
    h = 0x811C9DC5
    for ch in s:
        h ^= ord(ch)
        h = (h * 0x01000193) & 0xFFFFFFFF
    return f"{h:08x}"

def idem_key(inp, body, attempt=1):
    return f'question-ladder:{inp["task"]}:t{inp["turn_index"]}:{fnv1a(body)}:a{attempt}'

def parse_lines(text):
    """JSON Lines -> list of dicts; anything that is not a JSON object with "k" is ignored."""
    out = []
    for line in (text or "").splitlines():
        try:
            obj = json.loads(line)
        except ValueError:
            continue
        if isinstance(obj, dict) and "k" in obj:
            out.append(obj)
    return out

body = compact(payload)  # hash exactly what you send
job_id = api("POST", "/run", body, headers={"Idempotency-Key": idem_key(payload, body)})["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") or "run failed")

lines = parse_lines(job["output"]["output"])
if not lines or lines[0]["k"] != "turn":
    raise RuntimeError("no turn line: re-ask once with a retry_note")
turn = lines[0]
hints = [l for l in lines if l["k"] == "hint"]  # show one at a time, only when the learner asks
scaffold = next((l for l in lines if l["k"] == "scaffold"), {"level": payload["scaffold_level"]})
if lines[-1]["k"] != "end" or job.get("truncated"):
    print("warning: the reply was cut short")

print(f'Tutor [{turn["concept"]}]: {turn["say"]}')
print(f'{len(hints)} hints ready, support level {scaffold["level"]}, {job["charged_credits"]} credits')
function fnv1a(s) { // the app's own 32-bit FNV-1a
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 0x01000193) >>> 0;
  }
  return h.toString(16).padStart(8, "0");
}
// <slug>:<task>:t<turn_index>:<hash of the body>:a<attempt>
const idemKey = (input, attempt = 1) =>
  `question-ladder:${input.task}:t${input.turn_index}:${fnv1a(JSON.stringify(input))}:a${attempt}`;

function parseLines(text) { // JSON Lines -> objects; anything else is ignored
  const out = [];
  for (const line of String(text ?? "").split(/\r?\n/)) {
    try {
      const o = JSON.parse(line);
      if (o && typeof o === "object" && o.k) out.push(o);
    } catch { /* not JSON */ }
  }
  return out;
}

const { job_id } = await api("POST", "/run", payload, { "Idempotency-Key": idemKey(payload) });

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 lines = parseLines(job.output.output);
const turn = lines[0];
if (!turn || turn.k !== "turn") throw new Error("no turn line: re-ask once with a retry_note");
const hints = lines.filter((l) => l.k === "hint"); // show one at a time, only when the learner asks
const scaffold = lines.find((l) => l.k === "scaffold") ?? { level: payload.scaffold_level };
if (lines.at(-1).k !== "end" || job.truncated) console.warn("the reply was cut short");

console.log(`Tutor [${turn.concept}]: ${turn.say}`);
console.log(`${hints.length} hints ready, support level ${scaffold.level}, ${job.charged_credits} credits`);
// at package level:
func idemKey(task string, turnIndex int, body []byte, attempt int) string {
	h := fnv.New32a() // the same 32-bit FNV-1a the app uses
	h.Write(body)
	return fmt.Sprintf("question-ladder:%s:t%d:%08x:a%d", task, turnIndex, h.Sum32(), attempt)
}

func parseLines(text string) []map[string]any { // anything that is not a JSON object with "k" is ignored
	var out []map[string]any
	for _, line := range strings.Split(text, "\n") {
		var l map[string]any
		if json.Unmarshal([]byte(strings.TrimSpace(line)), &l) == nil && l["k"] != nil {
			out = append(out, l)
		}
	}
	return out
}

// ... in main(), after step 3:
body, _ := json.Marshal(payload) // hash exactly the bytes you send
var started struct{ JobID string `json:"job_id"` }
err := call("POST", "/run", json.RawMessage(body),
	map[string]string{"Idempotency-Key": idemKey("open", 1, body, 1)}, &started)
if err != nil {
	log.Fatal(err)
}

var job struct {
	Status         string `json:"status"`
	ChargedCredits int64  `json:"charged_credits"`
	Truncated      bool   `json:"truncated"`
	Output         struct {
		Output string `json:"output"`
	} `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, 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("run failed")
}

var turn map[string]any
hints, scaffoldLevel := 0, 3.0
for _, l := range parseLines(job.Output.Output) {
	switch l["k"] {
	case "turn":
		turn = l
	case "hint":
		hints++ // show one at a time, only when the learner asks
	case "scaffold":
		if v, ok := l["level"].(float64); ok {
			scaffoldLevel = v
		}
	}
}
if turn == nil {
	log.Fatal("no turn line: re-ask once with a retry_note")
}
fmt.Printf("Tutor [%v]: %v\n%d hints ready, support level %v, %d credits\n",
	turn["concept"], turn["say"], hints, scaffoldLevel, job.ChargedCredits)
static String fnv1a(String s) { // the app's own 32-bit FNV-1a
    int h = 0x811c9dc5;
    for (int i = 0; i < s.length(); i++) {
        h ^= s.charAt(i);
        h *= 0x01000193;
    }
    return String.format("%08x", h);
}

static String idemKey(JsonNode input, String body, int attempt) {
    return "question-ladder:" + input.get("task").asText() + ":t" + input.get("turn_index").asInt()
        + ":" + fnv1a(body) + ":a" + attempt;
}

static List<JsonNode> parseLines(String text) { // anything that is not a JSON object with "k" is ignored
    var out = new ArrayList<JsonNode>();
    for (String line : text.split("\\R")) {
        try {
            JsonNode l = JSON.readTree(line);
            if (l != null && l.isObject() && l.has("k")) out.add(l);
        } catch (Exception e) { /* not JSON */ }
    }
    return out;
}

// ... in main(), after step 3:
String body = JSON.writeValueAsString(payload); // hash exactly what you send
String jobId = api("POST", "/run", body, Map.of("Idempotency-Key", idemKey(payload, body, 1)))
    .get("job_id").asText();

JsonNode job;
do {
    Thread.sleep(1500);
    job = api("GET", "/jobs/" + jobId, null, Map.of());
} while (!job.get("status").asText().matches("succeeded|failed"));
if (job.get("status").asText().equals("failed")) throw new RuntimeException("run failed: " + job.get("error"));

List<JsonNode> lines = parseLines(job.path("output").path("output").asText(""));
if (lines.isEmpty() || !lines.get(0).get("k").asText().equals("turn"))
    throw new RuntimeException("no turn line: re-ask once with a retry_note");
JsonNode turn = lines.get(0);
int hints = 0, scaffoldLevel = 3;
for (JsonNode l : lines) {
    if (l.get("k").asText().equals("hint")) hints++; // show one at a time, only when the learner asks
    if (l.get("k").asText().equals("scaffold")) scaffoldLevel = l.get("level").asInt();
}
System.out.println("Tutor [" + turn.get("concept").asText() + "]: " + turn.get("say").asText());
System.out.println(hints + " hints ready, support level " + scaffoldLevel + ", "
    + job.get("charged_credits") + " credits");
def fnv1a(s) # the app's own 32-bit FNV-1a, over UTF-16 code units
  h = 0x811c9dc5
  s.encode("UTF-16LE").unpack("v*").each { |u| h = ((h ^ u) * 0x01000193) & 0xffffffff }
  format("%08x", h)
end

def idem_key(input, body, attempt = 1)
  "question-ladder:#{input["task"]}:t#{input["turn_index"]}:#{fnv1a(body)}:a#{attempt}"
end

def parse_lines(text) # JSON Lines -> hashes; anything else is ignored
  text.to_s.each_line.filter_map do |line|
    obj = (JSON.parse(line) rescue nil)
    obj if obj.is_a?(Hash) && obj["k"]
  end
end

body = payload.to_json # hash exactly what you send
job_id = api("POST", "/run", body, { "Idempotency-Key" => idem_key(payload, body) })["job_id"]

job = nil
loop do
  job = api("GET", "/jobs/#{job_id}")
  break if %w[succeeded failed].include?(job["status"])
  sleep 1.5
end
raise "run failed: #{job["error"]}" if job["status"] == "failed"

lines = parse_lines(job.dig("output", "output"))
turn = lines.first
raise "no turn line: re-ask once with a retry_note" unless turn && turn["k"] == "turn"
hints = lines.select { |l| l["k"] == "hint" } # show one at a time, only when the learner asks
scaffold = lines.find { |l| l["k"] == "scaffold" } || { "level" => payload["scaffold_level"] }
warn "the reply was cut short" if lines.last["k"] != "end" || job["truncated"]

puts "Tutor [#{turn["concept"]}]: #{turn["say"]}"
puts "#{hints.size} hints ready, support level #{scaffold["level"]}, #{job["charged_credits"]} credits"
function idem_key(array $input, string $body, int $attempt = 1): string {
    // <slug>:<task>:t<turn_index>:<hash>:a<attempt>, with PHP's built-in 32-bit FNV-1a
    return "question-ladder:{$input['task']}:t{$input['turn_index']}:" . hash("fnv1a32", $body) . ":a$attempt";
}

function parse_lines(?string $text): array { // JSON Lines -> arrays; anything else is ignored
    $out = [];
    foreach (preg_split('/\r?\n/', $text ?? "") as $line) {
        $o = json_decode($line, true);
        if (is_array($o) && isset($o["k"])) $out[] = $o;
    }
    return $out;
}

$body  = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); // hash what you send
$jobId = api("POST", "/run", $body, ["Idempotency-Key" => idem_key($payload, $body)])["job_id"];

do {
    usleep(1500000);
    $job = api("GET", "/jobs/$jobId");
} while (!in_array($job["status"], ["succeeded", "failed"], true));
if ($job["status"] === "failed") throw new Exception("run failed");

$lines = parse_lines($job["output"]["output"] ?? "");
$turn  = $lines[0] ?? null;
if (!$turn || $turn["k"] !== "turn") throw new Exception("no turn line: re-ask once with a retry_note");
$hints = array_values(array_filter($lines, fn($l) => $l["k"] === "hint")); // reveal on request only
$scaffoldLevel = $payload["scaffold_level"];
foreach ($lines as $l) if ($l["k"] === "scaffold") $scaffoldLevel = $l["level"];

echo "Tutor [{$turn['concept']}]: {$turn['say']}\n";
echo count($hints) . " hints ready, support level $scaffoldLevel, {$job['charged_credits']} credits\n";
static string Fnv1a(string s) // the app's own 32-bit FNV-1a
{
    uint h = 0x811c9dc5;
    foreach (char ch in s) { h ^= ch; h = unchecked(h * 0x01000193); }
    return h.ToString("x8");
}
static string IdemKey(Dictionary<string, object> input, string body, int attempt = 1) =>
    $"question-ladder:{input["task"]}:t{input["turn_index"]}:{Fnv1a(body)}:a{attempt}";

static List<JsonElement> ParseLines(string? text) // anything that is not a JSON object with "k" is ignored
{
    var output = new List<JsonElement>();
    foreach (var line in (text ?? "").Split('\n'))
    {
        try
        {
            var l = JsonDocument.Parse(line).RootElement;
            if (l.ValueKind == JsonValueKind.Object && l.TryGetProperty("k", out _)) output.Add(l);
        }
        catch (JsonException) { /* not JSON */ }
    }
    return output;
}

var body = JsonSerializer.Serialize(payload); // hash exactly what you send
var started = await QuestionLadder.ApiAsync(HttpMethod.Post, "/run", body,
    new() { ["Idempotency-Key"] = IdemKey(payload, body) });
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
string status;
do
{
    await Task.Delay(1500);
    job = await QuestionLadder.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
    status = job.GetProperty("status").GetString()!;
} while (status is not ("succeeded" or "failed"));
if (status == "failed") throw new Exception("run failed");

var lines = ParseLines(job.GetProperty("output").GetProperty("output").GetString());
if (lines.Count == 0 || lines[0].GetProperty("k").GetString() != "turn")
    throw new Exception("no turn line: re-ask once with a retry_note");
var turn = lines[0];
var hints = lines.Count(l => l.GetProperty("k").GetString() == "hint"); // reveal on request only
var scaffoldLevel = lines.Where(l => l.GetProperty("k").GetString() == "scaffold")
    .Select(l => l.GetProperty("level").GetInt32()).DefaultIfEmpty(3).First();

Console.WriteLine($"Tutor [{turn.GetProperty("concept")}]: {turn.GetProperty("say")}");
Console.WriteLine($"{hints} hints ready, support level {scaffoldLevel}, {job.GetProperty("charged_credits")} credits");

Step 5 - Stream the next turn as it is written

POST /run-stream

/run-stream takes the same body and headers as /run and answers with server-sent events. Each event is an event: line, a data: line of JSON, and a blank line:

The sample is the second turn of the session: the learner answers step 4's question, so the client sends answer with the one-line transcript, the tutor's question, its concept as focus_concept and turn_index 3, then applies the assess line to its own concept map.

# Turn 2: the learner answers the tutor's question. Build the next stateless body from open.json.
TURN=$(jq -c 'select(.k == "turn")' reply.jsonl)
jq -c --argjson t "$TURN" --slurpfile c concepts.json '. + {
  task: "answer",
  concepts_json: ($c[0] | tojson),
  transcript: ("T1 TUTOR question [" + $t.concept + "]: " + $t.say),
  question: $t.say,
  focus_concept: $t.concept,
  answer: "I think in summer the Earth is closer to the Sun, so more heat reaches us.",
  confidence: 4,
  hints_revealed: 0,
  turn_index: 3
}' open.json > answer.json

BODY=$(cat answer.json)
HASH=$(printf %s "$BODY" | shasum | cut -c1-8)
curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: question-ladder:answer:t3:$HASH:a1" \
  -d "$BODY" | tee stream.txt
# event: job
# data: {"job_id":"job_..."}
#
# event: tick
# data: {"t":4}
#
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":...,"output":{"output":"...JSON Lines..."}}

# Parse the reply from the done event, never from the deltas.
sed -n '/^event: done/{n;s/^data: *//p;}' stream.txt | jq -r '(.job // .) | .output.output' \
  | jq -R -c 'fromjson? | select(type == "object" and has("k"))' > reply2.jsonl
jq -r 'select(.k == "turn") | "Tutor: \(.say)"' reply2.jsonl

# Apply assess mastery to concepts already on the map (unknown ids change nothing).
jq --slurpfile r reply2.jsonl 'map(. as $c
  | ([$r[] | select(.k == "assess" and .concept == $c.id)] | first) as $a
  | if $a then .mastery = $a.mastery | .seen += 1 else . end)' concepts.json > concepts.next.json \
  && mv concepts.next.json concepts.json
# suggest lines are shown to the learner, never added to the map automatically
jq -r 'select(.k == "suggest") | "Suggested concept (not added): \(.concept_label) - \(.why)"' reply2.jsonl
# Turn 2: the learner answers. Every field is re-sent; the server keeps nothing between turns.
nxt = dict(payload,
    task="answer",
    concepts_json=compact(concepts),
    transcript=f'T1 TUTOR question [{turn["concept"]}]: {turn["say"]}',
    question=turn["say"],
    focus_concept=turn["concept"],
    answer="I think in summer the Earth is closer to the Sun, so more heat reaches us.",
    confidence=4,                     # the learner's 1-5 self-rating
    scaffold_level=scaffold["level"],
    hints_revealed=0,                 # hints the learner opened before answering
    turn_index=3,                     # T2 is the learner's line, T3 the tutor's reply
)
body = compact(nxt)
res = requests.post(API + "/run-stream", data=body, stream=True, timeout=300, headers={
    "Authorization": f"Bearer {TOKEN}",
    "Content-Type": "application/json",
    "Idempotency-Key": idem_key(nxt, body),
})
res.raise_for_status()

event, job = None, None
for raw in res.iter_lines(decode_unicode=True):
    if raw.startswith("event:"):
        event = raw[6:].strip()
    elif raw.startswith("data:"):
        data = json.loads(raw[5:].strip())
        if event == "delta":
            print(data["text"], end="", flush=True)  # progress only
        elif event == "done":
            job = data.get("job", data)
        elif event == "error":
            raise RuntimeError(data.get("message", data))
        # "job" carries {job_id}; "tick" is a heartbeat

# Apply the reply to YOUR map: only ids that already exist move.
by_id = {c["id"]: c for c in concepts}
for l in parse_lines(job["output"]["output"]):  # parse the final text, not the deltas
    if l["k"] == "turn":
        print("\nTutor:", l["say"])
    elif l["k"] == "assess" and l.get("concept") in by_id:
        c = by_id[l["concept"]]
        c["mastery"], c["seen"] = round(float(l["mastery"]), 2), c["seen"] + 1
        print(f'  {c["label"]}: {l["verdict"]}, mastery now {c["mastery"]}')
    elif l["k"] == "misconception":
        print("  misconception:", l["belief"])
    elif l["k"] == "suggest":
        print(f'  suggested concept (not added): {l["concept_label"]} - {l["why"]}')
// Turn 2: the learner answers. Every field is re-sent; the server keeps nothing between turns.
const next = {
  ...payload,
  task: "answer",
  concepts_json: JSON.stringify(concepts),
  transcript: `T1 TUTOR question [${turn.concept}]: ${turn.say}`,
  question: turn.say,
  focus_concept: turn.concept,
  answer: "I think in summer the Earth is closer to the Sun, so more heat reaches us.",
  confidence: 4,                  // the learner's 1-5 self-rating
  scaffold_level: scaffold.level,
  hints_revealed: 0,              // hints the learner opened before answering
  turn_index: 3,                  // T2 is the learner's line, T3 the tutor's reply
};

const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": idemKey(next),
  },
  body: JSON.stringify(next),
});
if (!res.ok) throw new Error(`run-stream: HTTP ${res.status}`);

const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = "", done = null;
for (;;) {
  const { value, done: end } = await reader.read();
  if (end) break;
  buf += value.replace(/\r\n/g, "\n");
  let i;
  while ((i = buf.indexOf("\n\n")) >= 0) {
    const frame = buf.slice(0, i);
    buf = buf.slice(i + 2);
    const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
    const data = /^data:\s?(.*)$/m.exec(frame)?.[1];
    if (!name || data === undefined) continue;
    const d = JSON.parse(data);
    if (name === "delta") console.log("+", d.text.length, "chars"); // progress only
    else if (name === "done") done = d.job ?? d;
    else if (name === "error") throw new Error(d.message ?? "stream error");
    // "job" carries {job_id}; "tick" is a heartbeat
  }
}

// Apply the reply to YOUR map: only ids that already exist move.
const byId = new Map(concepts.map((c) => [c.id, c]));
for (const l of parseLines(done.output.output)) { // parse the final text, not the deltas
  if (l.k === "turn") console.log("Tutor:", l.say);
  else if (l.k === "assess" && byId.has(l.concept)) {
    const c = byId.get(l.concept);
    c.mastery = Math.round(Number(l.mastery) * 100) / 100;
    c.seen += 1;
    console.log(`  ${c.label}: ${l.verdict}, mastery now ${c.mastery}`);
  } else if (l.k === "misconception") console.log("  misconception:", l.belief);
  else if (l.k === "suggest") console.log(`  suggested concept (not added): ${l.concept_label} - ${l.why}`);
}
// Turn 2: the learner answers. Every field is re-sent; the server keeps nothing between turns.
next := map[string]any{}
for k, v := range payload {
	next[k] = v
}
cj, _ = json.Marshal(concepts)
say, _ := turn["say"].(string)
focus, _ := turn["concept"].(string)
next["task"] = "answer"
next["concepts_json"] = string(cj)
next["transcript"] = "T1 TUTOR question [" + focus + "]: " + say
next["question"] = say
next["focus_concept"] = focus
next["answer"] = "I think in summer the Earth is closer to the Sun, so more heat reaches us."
next["confidence"] = 4      // the learner's 1-5 self-rating
next["scaffold_level"] = int(scaffoldLevel)
next["hints_revealed"] = 0  // hints the learner opened before answering
next["turn_index"] = 3      // T2 is the learner's line, T3 the tutor's reply
nextBody, _ := json.Marshal(next)

req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(nextBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idemKey("answer", 3, nextBody, 1))
res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()

var done struct {
	Status string `json:"status"`
	Output struct {
		Output string `json:"output"`
	} `json:"output"`
}
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<22) // done carries the whole reply on one line
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(line[6:])
	case strings.HasPrefix(line, "data:"):
		data := []byte(strings.TrimSpace(line[5:]))
		switch event {
		case "delta":
			fmt.Print(".") // progress only
		case "done":
			json.Unmarshal(data, &done)
		case "error":
			log.Fatalf("stream error: %s", data)
		} // "job" carries {job_id}; "tick" is a heartbeat
	}
}

// Apply the reply to YOUR map: only ids that already exist move.
for _, l := range parseLines(done.Output.Output) {
	switch l["k"] {
	case "turn":
		fmt.Println("\nTutor:", l["say"])
	case "assess":
		for i := range concepts {
			if concepts[i].ID == l["concept"] {
				if m, ok := l["mastery"].(float64); ok {
					concepts[i].Mastery = m
				}
				concepts[i].Seen++
				fmt.Printf("  %s: %v, mastery now %.2f\n", concepts[i].Label, l["verdict"], concepts[i].Mastery)
			}
		}
	case "suggest":
		fmt.Printf("  suggested concept (not added): %v - %v\n", l["concept_label"], l["why"])
	}
}
// Turn 2: the learner answers. Every field is re-sent; the server keeps nothing between turns.
ObjectNode next = payload.deepCopy()
    .put("task", "answer")
    .put("concepts_json", JSON.writeValueAsString(concepts))
    .put("transcript", "T1 TUTOR question [" + turn.get("concept").asText() + "]: " + turn.get("say").asText())
    .put("question", turn.get("say").asText())
    .put("focus_concept", turn.get("concept").asText())
    .put("answer", "I think in summer the Earth is closer to the Sun, so more heat reaches us.")
    .put("confidence", 4)               // the learner's 1-5 self-rating
    .put("scaffold_level", scaffoldLevel)
    .put("hints_revealed", 0)           // hints the learner opened before answering
    .put("turn_index", 3);              // T2 is the learner's line, T3 the tutor's reply
String nextBody = JSON.writeValueAsString(next);

var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + token)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", idemKey(next, nextBody, 1))
    .POST(HttpRequest.BodyPublishers.ofString(nextBody))
    .build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());

String event = "";
JsonNode done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
    if (line.startsWith("event:")) event = line.substring(6).trim();
    else if (line.startsWith("data:")) {
        JsonNode d = JSON.readTree(line.substring(5).trim());
        switch (event) {
            case "delta" -> System.out.print("."); // progress only
            case "done"  -> done = d.has("job") ? d.get("job") : d;
            case "error" -> throw new RuntimeException(d.toString());
            default      -> { } // job, tick
        }
    }
}

// Apply the reply to YOUR map: only ids that already exist move.
for (JsonNode l : parseLines(done.path("output").path("output").asText(""))) {
    switch (l.get("k").asText()) {
        case "turn" -> System.out.println("\nTutor: " + l.get("say").asText());
        case "assess" -> {
            for (JsonNode c : concepts) {
                if (!c.get("id").asText().equals(l.path("concept").asText())) continue;
                ((ObjectNode) c).put("mastery", l.get("mastery").asDouble()).put("seen", c.get("seen").asInt() + 1);
                System.out.println("  " + c.get("label").asText() + ": " + l.get("verdict").asText()
                    + ", mastery now " + c.get("mastery").asDouble());
            }
        }
        case "suggest" -> System.out.println("  suggested concept (not added): "
            + l.get("concept_label").asText() + " - " + l.get("why").asText());
        default -> { }
    }
}
# Turn 2: the learner answers. Every field is re-sent; the server keeps nothing between turns.
nxt = payload.merge(
  "task" => "answer",
  "concepts_json" => concepts.to_json,
  "transcript" => "T1 TUTOR question [#{turn["concept"]}]: #{turn["say"]}",
  "question" => turn["say"],
  "focus_concept" => turn["concept"],
  "answer" => "I think in summer the Earth is closer to the Sun, so more heat reaches us.",
  "confidence" => 4,                      # the learner's 1-5 self-rating
  "scaffold_level" => scaffold["level"],
  "hints_revealed" => 0,                  # hints the learner opened before answering
  "turn_index" => 3                       # T2 is the learner's line, T3 the tutor's reply
)
body = nxt.to_json

uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{$token}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idem_key(nxt, body)
req.body = body

done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |http|
  http.request(req) do |res|
    buf = +""
    event = nil
    res.read_body do |chunk|
      buf << chunk
      while (i = buf.index("\n"))
        line = buf.slice!(0..i).strip
        if line.start_with?("event:")
          event = line.delete_prefix("event:").strip
        elsif line.start_with?("data:")
          d = JSON.parse(line.delete_prefix("data:").strip)
          case event
          when "delta" then print "." # progress only
          when "done"  then done = d["job"] || d
          when "error" then raise d.to_s
          end # "job" carries {job_id}; "tick" is a heartbeat
        end
      end
    end
  end
end

# Apply the reply to YOUR map: only ids that already exist move.
parse_lines(done.dig("output", "output")).each do |l|
  case l["k"]
  when "turn" then puts "\nTutor: #{l["say"]}"
  when "assess"
    c = concepts.find { |x| x["id"] == l["concept"] } or next
    c["mastery"] = l["mastery"].to_f.round(2)
    c["seen"] += 1
    puts "  #{c["label"]}: #{l["verdict"]}, mastery now #{c["mastery"]}"
  when "suggest" then puts "  suggested concept (not added): #{l["concept_label"]} - #{l["why"]}"
  end
end
// Turn 2: the learner answers. Every field is re-sent; the server keeps nothing between turns.
$next = array_merge($payload, [
    "task"           => "answer",
    "concepts_json"  => json_encode($concepts, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
    "transcript"     => "T1 TUTOR question [{$turn['concept']}]: {$turn['say']}",
    "question"       => $turn["say"],
    "focus_concept"  => $turn["concept"],
    "answer"         => "I think in summer the Earth is closer to the Sun, so more heat reaches us.",
    "confidence"     => 4,              // the learner's 1-5 self-rating
    "scaffold_level" => $scaffoldLevel,
    "hints_revealed" => 0,              // hints the learner opened before answering
    "turn_index"     => 3,              // T2 is the learner's line, T3 the tutor's reply
]);
$body = json_encode($next, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

$buf = ""; $event = null; $done = null; $err = null;
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST          => true,
    CURLOPT_POSTFIELDS    => $body,
    CURLOPT_TIMEOUT       => 300,
    CURLOPT_HTTPHEADER    => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Idempotency-Key: " . idem_key($next, $body),
    ],
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$event, &$done, &$err) {
        $buf .= $chunk;
        while (($i = strpos($buf, "\n")) !== false) {
            $line = trim(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:")) {
                $d = json_decode(trim(substr($line, 5)), true);
                if ($event === "delta") echo ".";                  // progress only
                elseif ($event === "done") $done = $d["job"] ?? $d;
                elseif ($event === "error") $err = $d;              // "job" and "tick" are skipped
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);
if ($err) throw new Exception(json_encode($err));

// Apply the reply to YOUR map: only ids that already exist move.
foreach (parse_lines($done["output"]["output"] ?? "") as $l) {
    if ($l["k"] === "turn") echo "\nTutor: {$l['say']}\n";
    if ($l["k"] === "assess") {
        foreach ($concepts as &$c) {
            if ($c["id"] !== ($l["concept"] ?? null)) continue;
            $c["mastery"] = round((float) $l["mastery"], 2);
            $c["seen"]++;
            echo "  {$c['label']}: {$l['verdict']}, mastery now {$c['mastery']}\n";
        }
        unset($c);
    }
    if ($l["k"] === "suggest") echo "  suggested concept (not added): {$l['concept_label']} - {$l['why']}\n";
}
// Turn 2: the learner answers. Every field is re-sent; the server keeps nothing between turns.
var next = new Dictionary<string, object>(payload)
{
    ["task"] = "answer",
    ["concepts_json"] = JsonSerializer.Serialize(concepts),
    ["transcript"] = $"T1 TUTOR question [{turn.GetProperty("concept")}]: {turn.GetProperty("say")}",
    ["question"] = turn.GetProperty("say").GetString()!,
    ["focus_concept"] = turn.GetProperty("concept").GetString()!,
    ["answer"] = "I think in summer the Earth is closer to the Sun, so more heat reaches us.",
    ["confidence"] = 4,               // the learner's 1-5 self-rating
    ["scaffold_level"] = scaffoldLevel,
    ["hints_revealed"] = 0,           // hints the learner opened before answering
    ["turn_index"] = 3,               // T2 is the learner's line, T3 the tutor's reply
};
var nextBody = JsonSerializer.Serialize(next);

var req = new HttpRequestMessage(HttpMethod.Post, QuestionLadder.Api + "/run-stream")
{
    Content = new StringContent(nextBody, Encoding.UTF8, "application/json"),
};
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", QuestionLadder.Token);
req.Headers.Add("Idempotency-Key", IdemKey(next, nextBody));

using var res = await QuestionLadder.Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
    if (line.StartsWith("event:")) evt = line[6..].Trim();
    else if (line.StartsWith("data:"))
    {
        var data = line[5..].Trim();
        if (evt == "delta") Console.Write("."); // progress only
        else if (evt == "done") done = data;
        else if (evt == "error") throw new Exception(data);
    }   // "job" carries {job_id}; "tick" is a heartbeat
}

var final = JsonDocument.Parse(done!).RootElement;
if (final.TryGetProperty("job", out var wrapped)) final = wrapped;

// Apply the reply to YOUR map: only ids that already exist move.
foreach (var l in ParseLines(final.GetProperty("output").GetProperty("output").GetString()))
{
    var k = l.GetProperty("k").GetString();
    if (k == "turn") Console.WriteLine($"\nTutor: {l.GetProperty("say")}");
    else if (k == "assess")
    {
        var c = concepts.FirstOrDefault(x => (string)x["id"] == l.GetProperty("concept").GetString());
        if (c == null) continue;
        c["mastery"] = Math.Round(l.GetProperty("mastery").GetDouble(), 2);
        c["seen"] = (int)c["seen"] + 1;
        Console.WriteLine($"  {c["label"]}: {l.GetProperty("verdict")}, mastery now {c["mastery"]}");
    }
    else if (k == "suggest")
        Console.WriteLine($"  suggested concept (not added): {l.GetProperty("concept_label")} - {l.GetProperty("why")}");
}

Treat delta as progress only and parse the reply from done. A browser in particular may get only tick heartbeats and then one done carrying the whole output, and some proxies buffer the same way. EventSource only speaks GET, so read the fetch body as the JavaScript sample does. On an idempotent replay the server may answer with a plain JSON envelope instead of a stream, so check Content-Type before parsing frames.

Worked examples, one per task

One dialogue on "Why does Earth have seasons?" (mode concept, learner teen, support dial at 3) with five concepts: c1 axial tilt, c2 direct vs slanted sunlight, c3 day length, c4 distance from the Sun, c5 opposite hemispheres. Each request is complete: send it as-is to /estimate, /run or /run-stream. The mastery numbers in concepts_json are what the app holds after applying the earlier replies. Each reply is the job's output.output; real wording will differ. The Idempotency-Key hash is the FNV-1a of the compact (single-line) body; the requests are shown indented for reading.

1 - open

The first tutor turn on a fresh dialogue: empty transcript, every concept at mastery 0 and never seen. The tutor diagnoses before teaching, so it aims a question at the most likely misconception (distance from the Sun).

Request

curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: question-ladder:open:t1:73965910:a1" \
  --data-binary @- <<'JSON'
{
  "task": "open",
  "topic": "Why does Earth have seasons?",
  "mode": "concept",
  "learner": "teen",
  "passage": "",
  "concepts_json": "[{\"id\":\"c1\",\"label\":\"axial tilt\",\"mastery\":0,\"seen\":0},{\"id\":\"c2\",\"label\":\"direct vs slanted sunlight\",\"mastery\":0,\"seen\":0},{\"id\":\"c3\",\"label\":\"day length\",\"mastery\":0,\"seen\":0},{\"id\":\"c4\",\"label\":\"distance from the Sun\",\"mastery\":0,\"seen\":0},{\"id\":\"c5\",\"label\":\"opposite hemispheres\",\"mastery\":0,\"seen\":0}]",
  "transcript": "",
  "question": "",
  "focus_concept": "",
  "answer": "",
  "confidence": 0,
  "hint_level": 3,
  "scaffold_level": 3,
  "hints_revealed": 0,
  "turn_index": 1,
  "clip_note": ""
}
JSON

Reply (output.output)

{"k":"turn","task":"open","type":"question","concept":"c4","say":"Before any explanation, I want your own idea first. Why do you think it is warmer in summer than in winter where you live - what is different about Earth in those two seasons?"}
{"k":"hint","level":1,"text":"Think about Earth's yearly trip around the Sun. What could change during that trip?"}
{"k":"hint","level":2,"text":"People usually pick one of two ideas: how far away the Sun is, or the way Earth leans. Which one sounds right to you, and why?"}
{"k":"hint","level":3,"text":"Picture one day in July and one in January. Say what you think is different about Earth's position or angle on those days, then say how that would make it warmer."}
{"k":"scaffold","level":3,"move":"hold","why":"First question: start at the support level the learner chose."}
{"k":"end"}

2 - answer

The learner answered T1 with the classic misconception and rated their confidence 4/5. The transcript now holds T1; question and focus_concept repeat the tutor's last turn; turn_index is 3 because the learner's answer becomes T2.

Request

curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: question-ladder:answer:t3:b70d6341:a1" \
  --data-binary @- <<'JSON'
{
  "task": "answer",
  "topic": "Why does Earth have seasons?",
  "mode": "concept",
  "learner": "teen",
  "passage": "",
  "concepts_json": "[{\"id\":\"c1\",\"label\":\"axial tilt\",\"mastery\":0,\"seen\":0},{\"id\":\"c2\",\"label\":\"direct vs slanted sunlight\",\"mastery\":0,\"seen\":0},{\"id\":\"c3\",\"label\":\"day length\",\"mastery\":0,\"seen\":0},{\"id\":\"c4\",\"label\":\"distance from the Sun\",\"mastery\":0,\"seen\":0},{\"id\":\"c5\",\"label\":\"opposite hemispheres\",\"mastery\":0,\"seen\":0}]",
  "transcript": "T1 TUTOR question [c4]: Before any explanation, I want your own idea first. Why do you think it is warmer in summer than in winter where you live - what is different about Earth in those two seasons?",
  "question": "Before any explanation, I want your own idea first. Why do you think it is warmer in summer than in winter where you live - what is different about Earth in those two seasons?",
  "focus_concept": "c4",
  "answer": "I think in summer the Earth is closer to the Sun, so more heat reaches us.",
  "confidence": 4,
  "hint_level": 3,
  "scaffold_level": 3,
  "hints_revealed": 0,
  "turn_index": 3,
  "clip_note": ""
}
JSON

Reply (output.output)

{"k":"turn","task":"answer","type":"question","concept":"c4","say":"That is the most common idea people start with, so it is worth testing rather than taking on trust. When it is summer in New York, it is winter in Sydney, Australia, in the very same week. If being closer to the Sun caused summer, what would you expect Sydney to be having that week?"}
{"k":"assess","concept":"c4","verdict":"misconception","mastery":0.15,"evidence":"in summer the Earth is closer to the Sun, so more heat reaches us","note":"The answer treats a change in distance from the Sun as the cause of the seasons."}
{"k":"misconception","concept":"c4","belief":"Summer happens because the whole Earth moves closer to the Sun.","probe":"If the whole Earth were closer, why would the two hemispheres have opposite seasons at the same time?"}
{"k":"hint","level":1,"text":"Both cities are on the same planet. Where is that planet on its orbit during that one week?"}
{"k":"hint","level":2,"text":"If distance were the cause, both hemispheres would get the same effect at the same time. Is that what happens?"}
{"k":"hint","level":3,"text":"Start from: \"In that week New York and Sydney are about the same distance from the Sun, so...\" and finish the sentence."}
{"k":"scaffold","level":3,"move":"hold","why":"A first wrong answer: hold support and let the contradiction do the work."}
{"k":"end"}

3 - stuck

The learner pressed "I'm stuck" on T3 without answering. answer is empty and confidence is 0. The app had already applied the last assess, so c4 carries mastery 0.08 and seen 1. turn_index is the next free number, 4 (the learner's stuck line takes it in the app's own record).

Request

curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: question-ladder:stuck:t4:3c4dae50:a1" \
  --data-binary @- <<'JSON'
{
  "task": "stuck",
  "topic": "Why does Earth have seasons?",
  "mode": "concept",
  "learner": "teen",
  "passage": "",
  "concepts_json": "[{\"id\":\"c1\",\"label\":\"axial tilt\",\"mastery\":0,\"seen\":0},{\"id\":\"c2\",\"label\":\"direct vs slanted sunlight\",\"mastery\":0,\"seen\":0},{\"id\":\"c3\",\"label\":\"day length\",\"mastery\":0,\"seen\":0},{\"id\":\"c4\",\"label\":\"distance from the Sun\",\"mastery\":0.08,\"seen\":1},{\"id\":\"c5\",\"label\":\"opposite hemispheres\",\"mastery\":0,\"seen\":0}]",
  "transcript": "T1 TUTOR question [c4]: Before any explanation, I want your own idea first. Why do you think it is warmer in summer than in winter where you live - what is different about Earth in those two seasons?\nT2 LEARNER answer (confidence 4/5): I think in summer the Earth is closer to the Sun, so more heat reaches us.\nT3 TUTOR question [c4]: That is the most common idea people start with, so it is worth testing rather than taking on trust. When it is summer in New York, it is winter in Sydney, Australia, in the very same week. If being closer to the Sun caused summer, what would you expect Sydney to be having that week?",
  "question": "That is the most common idea people start with, so it is worth testing rather than taking on trust. When it is summer in New York, it is winter in Sydney, Australia, in the very same week. If being closer to the Sun caused summer, what would you expect Sydney to be having that week?",
  "focus_concept": "c4",
  "answer": "",
  "confidence": 0,
  "hint_level": 3,
  "scaffold_level": 3,
  "hints_revealed": 0,
  "turn_index": 4,
  "clip_note": ""
}
JSON

Reply (output.output)

{"k":"turn","task":"stuck","type":"hint","concept":"c4","say":"Let's take one small step together. In any given week the whole Earth sits at one point on its orbit, so New York and Sydney are almost exactly the same distance from the Sun. Here is a smaller question: if two places are the same distance from the Sun, can distance alone make one of them hot and the other cold?"}
{"k":"assess","concept":"c4","verdict":"stuck","mastery":0.05,"evidence":"","note":"The learner asked for help before trying the contradiction question."}
{"k":"hint","level":1,"text":"Same distance means the same amount of sunshine from that cause, in both places."}
{"k":"hint","level":2,"text":"Answer yes or no first, then say what that tells you about distance as the reason for opposite seasons."}
{"k":"scaffold","level":4,"move":"up","why":"The learner was stuck, so the tutor started the worked step for them."}
{"k":"end"}

4 - check

Later in the same dialogue (T6 answered the smaller question, T7 asked about slanted light). The learner wants their DRAFT checked before committing. They opened one hint first, so hints_revealed is 1. turn_index is 9: the draft becomes T8.

Request

curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: question-ladder:check:t9:e73fa005:a1" \
  --data-binary @- <<'JSON'
{
  "task": "check",
  "topic": "Why does Earth have seasons?",
  "mode": "concept",
  "learner": "teen",
  "passage": "",
  "concepts_json": "[{\"id\":\"c1\",\"label\":\"axial tilt\",\"mastery\":0,\"seen\":0},{\"id\":\"c2\",\"label\":\"direct vs slanted sunlight\",\"mastery\":0,\"seen\":0},{\"id\":\"c3\",\"label\":\"day length\",\"mastery\":0,\"seen\":0},{\"id\":\"c4\",\"label\":\"distance from the Sun\",\"mastery\":0.37,\"seen\":3},{\"id\":\"c5\",\"label\":\"opposite hemispheres\",\"mastery\":0,\"seen\":0}]",
  "transcript": "T1 TUTOR question [c4]: Before any explanation, I want your own idea first. Why do you think it is warmer in summer than in winter where you live - what is different about Earth in those two seasons?\nT2 LEARNER answer (confidence 4/5): I think in summer the Earth is closer to the Sun, so more heat reaches us.\nT3 TUTOR question [c4]: That is the most common idea people start with, so it is worth testing rather than taking on trust. When it is summer in New York, it is winter in Sydney, Australia, in the very same week. If being closer to the Sun caused summer, what would you expect Sydney to be having that week?\nT4 LEARNER stuck: (no answer - asked for help)\nT5 TUTOR hint [c4]: Let's take one small step together. In any given week the whole Earth sits at one point on its orbit, so New York and Sydney are almost exactly the same distance from the Sun. Here is a smaller question: if two places are the same distance from the Sun, can distance alone make one of them hot and the other cold?\nT6 LEARNER answer (confidence 3/5): No. So it can't be the distance. Maybe Earth is tilted, so one half leans toward the Sun and the other half leans away?\nT7 TUTOR question [c2]: Yes - same distance but opposite seasons rules distance out, and you reached for the tilt on your own. Next rung: shine a flashlight straight down at a table, then tip it to a low angle. What happens to the patch of light, and what would that mean for how much warmth each bit of the table gets?",
  "question": "Yes - same distance but opposite seasons rules distance out, and you reached for the tilt on your own. Next rung: shine a flashlight straight down at a table, then tip it to a low angle. What happens to the patch of light, and what would that mean for how much warmth each bit of the table gets?",
  "focus_concept": "c2",
  "answer": "When the flashlight is slanted the patch gets bigger, so the same light is spread over more table and each bit gets less. In summer the days are also longer, so there is more time to heat up.",
  "confidence": 3,
  "hint_level": 3,
  "scaffold_level": 3,
  "hints_revealed": 1,
  "turn_index": 9,
  "clip_note": ""
}
JSON

Reply (output.output)

{"k":"turn","task":"check","type":"check","concept":"c2","say":"The spreading part holds up: the same light over a bigger patch means less warmth for each bit of ground. Before you commit, test the link to the seasons: in your draft, what decides whether sunlight reaches your part of Earth straight on or at a slant in a given month?"}
{"k":"assess","concept":"c2","verdict":"partial","mastery":0.45,"evidence":"the same light is spread over more table and each bit gets less","note":"The spreading effect is right, but the draft does not yet tie the angle of the light to Earth's tilt and the time of year."}
{"k":"scaffold","level":3,"move":"hold","why":"A partly right draft: keep the same support while the learner finds the missing link."}
{"k":"end"}

5 - summary

The learner ends the session after T9. Every turn so far fits the summary window, so nothing is omitted. Only c2 and c4 have seen above 0, so the reply carries exactly two recap lines.

Request

curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: question-ladder:summary:t10:6e88b75b:a1" \
  --data-binary @- <<'JSON'
{
  "task": "summary",
  "topic": "Why does Earth have seasons?",
  "mode": "concept",
  "learner": "teen",
  "passage": "",
  "concepts_json": "[{\"id\":\"c1\",\"label\":\"axial tilt\",\"mastery\":0,\"seen\":0},{\"id\":\"c2\",\"label\":\"direct vs slanted sunlight\",\"mastery\":0.26,\"seen\":1},{\"id\":\"c3\",\"label\":\"day length\",\"mastery\":0,\"seen\":0},{\"id\":\"c4\",\"label\":\"distance from the Sun\",\"mastery\":0.37,\"seen\":3},{\"id\":\"c5\",\"label\":\"opposite hemispheres\",\"mastery\":0,\"seen\":0}]",
  "transcript": "T1 TUTOR question [c4]: Before any explanation, I want your own idea first. Why do you think it is warmer in summer than in winter where you live - what is different about Earth in those two seasons?\nT2 LEARNER answer (confidence 4/5): I think in summer the Earth is closer to the Sun, so more heat reaches us.\nT3 TUTOR question [c4]: That is the most common idea people start with, so it is worth testing rather than taking on trust. When it is summer in New York, it is winter in Sydney, Australia, in the very same week. If being closer to the Sun caused summer, what would you expect Sydney to be having that week?\nT4 LEARNER stuck: (no answer - asked for help)\nT5 TUTOR hint [c4]: Let's take one small step together. In any given week the whole Earth sits at one point on its orbit, so New York and Sydney are almost exactly the same distance from the Sun. Here is a smaller question: if two places are the same distance from the Sun, can distance alone make one of them hot and the other cold?\nT6 LEARNER answer (confidence 3/5): No. So it can't be the distance. Maybe Earth is tilted, so one half leans toward the Sun and the other half leans away?\nT7 TUTOR question [c2]: Yes - same distance but opposite seasons rules distance out, and you reached for the tilt on your own. Next rung: shine a flashlight straight down at a table, then tip it to a low angle. What happens to the patch of light, and what would that mean for how much warmth each bit of the table gets?\nT8 LEARNER check (confidence 3/5): When the flashlight is slanted the patch gets bigger, so the same light is spread over more table and each bit gets less. In summer the days are also longer, so there is more time to heat up.\nT9 TUTOR check [c2]: The spreading part holds up: the same light over a bigger patch means less warmth for each bit of ground. Before you commit, test the link to the seasons: in your draft, what decides whether sunlight reaches your part of Earth straight on or at a slant in a given month?",
  "question": "The spreading part holds up: the same light over a bigger patch means less warmth for each bit of ground. Before you commit, test the link to the seasons: in your draft, what decides whether sunlight reaches your part of Earth straight on or at a slant in a given month?",
  "focus_concept": "c2",
  "answer": "",
  "confidence": 0,
  "hint_level": 3,
  "scaffold_level": 3,
  "hints_revealed": 0,
  "turn_index": 10,
  "clip_note": ""
}
JSON

Reply (output.output)

{"k":"turn","task":"summary","type":"summary","concept":"","say":"You started out thinking summer comes from Earth being closer to the Sun. When you compared New York and Sydney, you worked out for yourself that two places at the same distance cannot get opposite seasons from distance, and you named the tilt as the likely reason. With the flashlight, you showed that slanted light spreads over a bigger patch and warms each bit less. The step still open is linking the tilt to which hemisphere gets the straight-on light in a given month."}
{"k":"recap","concept":"c4","mastery":0.6,"evidence":"You reasoned that two places the same distance from the Sun cannot have opposite seasons because of distance.","next":"Explain to someone, without notes, why being closer to the Sun is not what causes summer."}
{"k":"recap","concept":"c2","mastery":0.4,"evidence":"You described slanted light spreading over a bigger patch so that each bit gets less warmth.","next":"Say which hemisphere gets the straight-on light in June and which in December, and why."}
{"k":"transfer","text":"Imagine a planet whose axis is not tilted at all, circling its star at a steady distance. Would it have seasons? Use what you worked out today to explain."}
{"k":"practice","text":"Shine a flashlight on paper at two angles, trace each patch of light, and compare their sizes."}
{"k":"end"}