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.
| Status | Typical error.code | Meaning |
|---|---|---|
400 | validation_error | The body failed validation (a field of the wrong type, an object where a scalar belongs); details names the field. |
401 | unauthorized | Invalid or expired app session. Get a new token (step 1). |
402 | payment_required | Insufficient credits: the balance is below min_credits for this run. Top up, or sign in for a personal token. |
403 | forbidden | The 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. |
404 | not_found | Unknown app (a misspelt slug in POST /guest), or an unknown job id, or a job that belongs to another token. |
409 | conflict | The Idempotency-Key was already used with a different body. |
429 | rate_limited | Rate 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.
| task | When | Turn type | Lines, in order |
|---|---|---|---|
open | The first tutor turn. Diagnoses before teaching with one question about the most basic concept, often a prediction that exposes a common misconception. | question | turn, hint x2-3 (levels 1, 2, 3), scaffold (at hint_level, move hold), parent in homework mode, end. No assess: nothing was answered yet. |
answer | The learner answered the last question. | question | turn (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. |
stuck | The learner pressed "I'm stuck"; answer may be empty. | hint | turn (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. |
check | The learner wants a DRAFT answer checked before committing to it. | check | turn (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. |
summary | The learner ends the session. | summary | turn (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.
| Field | Type | Notes |
|---|---|---|
task | string | open | answer | stuck | check | summary. See the table above. |
topic | string | What is being learned, e.g. Why does Earth have seasons? The app sends up to 300 characters. |
mode | string | concept | 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). |
learner | string | child (8-11, a parent beside them) | teen | adult | adult-background. |
passage | string | The 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_json | string (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. |
transcript | string | The 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. |
question | string | The tutor's most recent turn, verbatim (the app sends up to 700 characters). Empty on open. |
focus_concept | string | The concept id that turn targeted (its concept field). |
answer | string | The learner's newest answer for answer, or the draft for check (up to 1,500 characters). Empty for the other tasks. |
confidence | number | The learner's self-rating 1-5 for answer and check; 0 otherwise. |
hint_level | number | 1-5: the most support the learner asked for on the dial. |
scaffold_level | number | 1-5: the support level of the last turn (the last scaffold line's level; 3 to start). |
hints_revealed | number | How many of the tutor's prepared hints for the current question the learner opened before answering. |
turn_index | number | The 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_note | string | What the app cut before sending (passage sentences, older turns, a long answer), in plain words; empty when nothing was cut. |
retry_note | string, optional | A 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.
- Build the concept map (up to 8 ids
c1,c2, ... with a label,mastery0 andseen0) and sendopenwithturn_index1. - Show the
turnline'ssay. Keep thehintlines hidden and reveal one at a time when the learner asks; count how many were opened forhints_revealed. - 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 asquestionandfocus_concept, and the newturn_index. - Parse the JSON Lines reply and apply each
assessline to your own map: only ids that already exist in the map move (new mastery,seen+ 1). An unknown id changes nothing, and asuggestline is shown to the learner as a suggestion; neither is ever added to the map automatically. Store the lastscaffoldlevel as the nextscaffold_level. - 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.
| k | Fields | Where |
|---|---|---|
turn | task (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. |
assess | concept, verdict: correct | partial | misconception | stuck, mastery 0-1 after this answer, evidence (the learner's words, copied), note (one sentence). | answer, stuck, check. |
misconception | concept, belief (the faulty model, one sentence), probe (the contradiction question). | answer, check, when one showed. |
hint | level 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). |
scaffold | level 1-5, move: down | up | hold, why (one short sentence). | Every task except summary. |
correction | original (the learner's words), corrected, note. | Language mode, answer and check, 0-2. |
parent | text: one or two sentences telling the parent how to help without giving the answer. | Homework mode, every task except summary. |
suggest | concept (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. |
recap | concept, 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. |
transfer | text: one new question in a different context, not yet answered. | summary. |
practice | text: one short practice suggestion. | summary. |
end | No 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
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?
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
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
/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
/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:
job:{job_id}, sent once when the job starts.delta:{"text"}, the next piece of the reply.tick:{"t"}, a heartbeat with seconds elapsed.done: the whole finished job, includingoutput.outputandcharged_credits.error:{code, message}; the stream ends.
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"}