← Historical Figure Chat / API
Your token

Driving Historical Figure Chat from your own code

Historical Figure Chat is a session app. A conversation with a figure is a conversation with the platform: you create a session once, then send one message per exchange against the app's system prompt, and the platform keeps the history server-side. Everything below runs against the app API at https://api.skillsafe.ai/v1/app-api.

The one thing that makes this app different from most. The reply is not just prose. Every substantive claim the figure makes comes back on its own GROUND: line carrying an attestation grade — whether the claim is traceable to something they wrote, reported by somebody else, inferred from positions that are on record, disputed by the sources, or an extension into a world they never saw. The voice is allowed to be beautiful. The attestation is not allowed to be flattering. If you render only the prose and drop the ledger, you have built a different and considerably worse app.
The guardrails are enforced in the browser, before the turn is ever sent. Calling this API directly gets you the prompt-level enforcement only, and you should implement the year check yourself. The app refuses a figure who died after 1905, refuses a figure named by description rather than by name, refuses to reproduce a copyrighted work, and refuses to manufacture a present-day endorsement — and it does all four client-side, before any spend. Those four rules are also restated in the system prompt, so a direct integrator is not unprotected; the model is required to answer a request to speak with someone recently dead with a REFUSAL: line. But a prompt is a weaker instrument than a check, and the model cannot reliably know a death year you have not given it. Before you send an exchange for a figure your own users named, verify the death year yourself and refuse anything after 1905.

The response envelope

Every response is one of these two shapes, whatever the status code:

{"ok": true,  "data": { ... }}
{"ok": false, "error": {"code": "VALIDATION_ERROR", "message": "...", "details": { ... }}}

So check ok before you touch data. The helper in step 1 does that once.

Error codes

StatusCodeWhat it means
400VALIDATION_ERRORThe body was malformed - most often a missing content on an exchange.
401UNAUTHORIZEDNo token, or a token that has expired. Mint a new one.
402INSUFFICIENT_CREDITSThe balance is below min_credits. Nothing was charged and nothing was appended to the session.
403FORBIDDENA guest token tried to put a question. /me and /estimate work for guests; an exchange does not.
404NOT_FOUNDThe session is gone - deleted, or expired. Create a fresh one and send the same envelope; it carries the whole conversation.
409CONFLICTToo many live sessions. Delete some; the cap is twenty per user.
429RATE_LIMITEDBack off and retry. Never tight-loop.
500INTERNALTransient. Retry once - but see the warning about resending an exchange.

One code you will not find in that table is a refusal. A REFUSAL: line comes back inside a perfectly ordinary 200 with ok: true, because a refusal is a complete and correct reply and the app charges for it like any other. Treating it as an error and retrying it means paying twice to be told no twice. See step 10.

Do not blindly retry an exchange. A session message accepts no idempotency key. If a POST /sessions/{id}/messages times out, the question may still have landed — and resending it appends a second copy of the same question to the server-side history, bills you a second time, and leaves the figure answering a reader who apparently asked the same thing twice in a row. Instead, GET /sessions/{id}, count the messages with role: "assistant", and compare that against the number of replies you have accepted on this session. If the server holds more, the exchange landed: adopt the reply it is already holding. Count per session and not per conversation — this app rotates its session deliberately, so a conversation of forty exchanges is spread across two or more sessions and a conversation-wide count will never match.

1. A client and a token

One helper, used by every step below. Get a token from your token page — it reads the token this browser already holds for this app, so you never have to open a storage inspector.

# Every call in this guide reuses one token in one shell variable.
# Get yours from https://historical-figure-chat.skillsafe.ai/tokens.html — the page reads the
# token this browser already holds, so you never open the developer console.
export FIGURE_TOKEN="YOUR_TOKEN"

# A guest token is enough for /me and /estimate. Putting a question is metered
# and needs a personal token, which comes from signing in.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" -d '{"slug":"historical-figure-chat"}'
import json, urllib.request

BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"   # from https://historical-figure-chat.skillsafe.ai/tokens.html

def call(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data, method=method)
    req.add_header("Authorization", "Bearer " + TOKEN)
    if data:
        req.add_header("Content-Type", "application/json")
    try:
        with urllib.request.urlopen(req) as r:
            env = json.loads(r.read())
    except urllib.error.HTTPError as e:
        env = json.loads(e.read())
    # Every response is {"ok":..., "data":{...}} or {"ok":false,"error":{...}}.
    if not env.get("ok"):
        raise RuntimeError(env["error"]["code"] + ": " + env["error"]["message"])
    return env["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";   // from https://historical-figure-chat.skillsafe.ai/tokens.html

async function call(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      "Authorization": "Bearer " + TOKEN,
      ...(body ? { "Content-Type": "application/json" } : {})
    },
    body: body ? JSON.stringify(body) : undefined
  });
  const env = await res.json();
  if (!env.ok) throw new Error(env.error.code + ": " + env.error.message);
  return env.data;
}
package main

import (
    "bytes"; "encoding/json"; "errors"; "fmt"; "io"; "log"; "net/http"; "os"
)

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

// Read it from the environment rather than pasting it into the source:
//   token := os.Getenv("FIGURE_TOKEN")
var token = "YOUR_TOKEN" // from https://historical-figure-chat.skillsafe.ai/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"`
}

func call(method, path string, body []byte) (map[string]any, error) {
    if v := os.Getenv("FIGURE_TOKEN"); v != "" { token = v }
    var rdr io.Reader
    if body != nil { rdr = bytes.NewReader(body) }
    req, _ := http.NewRequest(method, base+path, rdr)
    req.Header.Set("Authorization", "Bearer "+token)
    if body != nil { req.Header.Set("Content-Type", "application/json") }
    res, err := http.DefaultClient.Do(req)
    if err != nil { return nil, err }
    defer res.Body.Close()
    raw, _ := io.ReadAll(res.Body)
    var env envelope
    if err := json.Unmarshal(raw, &env); err != nil { return nil, err }
    if !env.OK && env.Error != nil {
        return nil, errors.New(env.Error.Code + ": " + env.Error.Message)
    }
    var out map[string]any
    _ = json.Unmarshal(env.Data, &out)
    return out, nil
}
import java.net.URI;
import java.net.http.*;

// Requires a JSON library of your choice; the envelope shape is
// {"ok":true,"data":{...}} or {"ok":false,"error":{"code","message"}}.
class HistoricalFigureChat {
  static final String BASE = "https://api.skillsafe.ai/v1/app-api";
  static final String TOKEN = "YOUR_TOKEN"; // historical-figure-chat.skillsafe.ai/tokens.html
  static final HttpClient HTTP = HttpClient.newHttpClient();

  static String call(String method, String path, String body) throws Exception {
    var b = HttpRequest.newBuilder(URI.create(BASE + path))
        .header("Authorization", "Bearer " + TOKEN);
    if (body != null) {
      b = b.header("Content-Type", "application/json")
           .method(method, HttpRequest.BodyPublishers.ofString(body));
    } else {
      b = b.method(method, HttpRequest.BodyPublishers.noBody());
    }
    HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
    return res.body();   // parse and check env.ok before using env.data
  }
}
require "json"
require "net/http"
require "uri"

BASE  = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"   # from https://historical-figure-chat.skillsafe.ai/tokens.html

def call(method, path, body = nil)
  uri = URI(BASE + path)
  klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post,
            "DELETE" => Net::HTTP::Delete }.fetch(method)
  req = klass.new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  if body
    req["Content-Type"] = "application/json"
    req.body = JSON.generate(body)
  end
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  env = JSON.parse(res.body)
  raise "#{env['error']['code']}: #{env['error']['message']}" unless env["ok"]
  env["data"]
end
<?php
const BASE  = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";   // from https://historical-figure-chat.skillsafe.ai/tokens.html

function call(string $method, string $path, $body = null) {
    $headers = ["Authorization: Bearer " . TOKEN];
    $opts = ["http" => ["method" => $method, "ignore_errors" => true]];
    if ($body !== null) {
        $headers[] = "Content-Type: application/json";
        $opts["http"]["content"] = json_encode($body);
    }
    $opts["http"]["header"] = implode("\r\n", $headers);
    $raw = file_get_contents(BASE . $path, false, stream_context_create($opts));
    $env = json_decode($raw, true);
    if (empty($env["ok"])) {
        throw new RuntimeException($env["error"]["code"] . ": " . $env["error"]["message"]);
    }
    return $env["data"];
}
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

class HistoricalFigureChat {
  const string Base = "https://api.skillsafe.ai/v1/app-api";
  const string Token = "YOUR_TOKEN";  // historical-figure-chat.skillsafe.ai/tokens.html
  static readonly HttpClient Http = new HttpClient();

  static async Task<JsonElement> Call(string method, string path, string body) {
    var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
    req.Headers.Add("Authorization", "Bearer " + Token);
    if (body != null)
      req.Content = new StringContent(body, Encoding.UTF8, "application/json");
    var res = await Http.SendAsync(req);
    var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
    if (!env.GetProperty("ok").GetBoolean()) {
      var e = env.GetProperty("error");
      throw new Exception(e.GetProperty("code").GetString() + ": " +
                          e.GetProperty("message").GetString());
    }
    return env.GetProperty("data");
  }
}

2. Who am I — GET /me

Free. Returns exactly three fields: subject_type, subject_id and credits. Note what is not there — no name, no email, no id you can key a user record off. The signed-in test is subject_type === "user".

curl -s -X GET "https://api.skillsafe.ai/v1/app-api/me" \
  -H "Authorization: Bearer $FIGURE_TOKEN"
r = call("GET", "/me")
credits = r["credits"]
print(credits)
const r = await call("GET", "/me");
const credits = r["credits"];
console.log(credits);
r, err := call("GET", "/me", nil)
if err != nil { log.Fatal(err) }
fmt.Println(r)
var r = call("GET", "/me", null);
System.out.println(r);
r = call("GET", "/me")
puts r
$r = call("GET", "/me");
print_r($r);
var r = await Call("GET", "/me", null);
Console.WriteLine(r);

3. What an exchange costs — POST /estimate

Free, and it runs no job. Returns hold_credits (what is reserved before the exchange runs, priced at the full output cap), min_credits, model, model_alias and markup_bps. What you are actually charged comes back on the exchange itself and is usually well under the hold.

Estimate against a real envelope, not a short probe string. The envelope restates the figure's whole dossier and every fact established so far, so an exchange late in a conversation carries far more text than the opening one, and an estimate taken against a stub understates every hold you will ever place.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
  -H "Authorization: Bearer $FIGURE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"turn": "[FIGURE CHAT | EXCHANGE 4]\n...the full envelope from step 5..."}'
r = call("POST", "/estimate", body={
      "turn": "[FIGURE CHAT | EXCHANGE 4]\n...the full envelope from step 5..."
    })
hold = r["hold_credits"]
print(hold)
const r = await call("POST", "/estimate", {
    "turn": "[FIGURE CHAT | EXCHANGE 4]\n...the full envelope from step 5..."
  });
const hold = r["hold_credits"];
console.log(hold);
r, err := call("POST", "/estimate", []byte(`{"turn": "[FIGURE CHAT | EXCHANGE 4]\n...the full envelope from step 5..."}`))
if err != nil { log.Fatal(err) }
fmt.Println(r)
var r = call("POST", "/estimate", """
{"turn": "[FIGURE CHAT | EXCHANGE 4]\n...the full envelope from step 5..."}
""");
System.out.println(r);
r = call("POST", "/estimate", {"turn": "[FIGURE CHAT | EXCHANGE 4]\n...the full envelope from step 5..."})
puts r
$r = call("POST", "/estimate", json_decode('{"turn": "[FIGURE CHAT | EXCHANGE 4]\n...the full envelope from step 5..."}', true));
print_r($r);
var r = await Call("POST", "/estimate", @"{""turn"": ""[FIGURE CHAT | EXCHANGE 4]\n...the full envelope from step 5...""}");
Console.WriteLine(r);

4. Open a session — POST /sessions

One session per conversation, at least to begin with. Returns session_id. Sessions cap at twenty live per user and 200 messages each, so list and prune before you create, and delete when the reader takes their leave.

Because the envelope carries the whole conversation, the session is disposable. The app rotates it deliberately every thirty exchanges to stay clear of the message cap, and if one 404s mid-conversation it simply opens another and sends the same envelope. The figure does not notice, because the figure's memory was never in the session to begin with.

This is also why the retry-recovery count in the warning above is per session. After a rotation the new session holds two messages while your conversation holds sixty; comparing those two numbers tells you nothing.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/sessions" \
  -H "Authorization: Bearer $FIGURE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
r = call("POST", "/sessions", body={})
session_id = r["session_id"]
print(session_id)
const r = await call("POST", "/sessions", {});
const session_id = r["session_id"];
console.log(session_id);
r, err := call("POST", "/sessions", []byte(`{}`))
if err != nil { log.Fatal(err) }
fmt.Println(r)
var r = call("POST", "/sessions", """
{}
""");
System.out.println(r);
r = call("POST", "/sessions", {})
puts r
$r = call("POST", "/sessions", json_decode('{}', true));
print_r($r);
var r = await Call("POST", "/sessions", @"{}");
Console.WriteLine(r);

5. Build the exchange envelope

This is the actual work, and it is all caller-side. The message you send is a plain string — content takes text, not a JSON body with a task field, so the kind of turn is stated inside the envelope as an INSTRUCTION line rather than passed alongside it. There is no kind parameter to set. If you are looking for one, you are looking in the wrong layer.

Server-side history truncates oldest-pair-first as it grows, which destroys precisely the turns that set a conversation up — who this figure is, how their record survives, and what they have already committed to. So nothing load-bearing is kept in the conversation. Restate all of it, every time:

[FIGURE CHAT | EXCHANGE 4]
MODE: reconstruction-of-a-historical-figure
FIGURE: Socrates of Athens
LIVED: 470 BCE to 399 BCE
ERA: Athens, fifth century BCE
THEIR HORIZON: they can know nothing after 399 BCE.
HOW THE RECORD SURVIVES: Written down by others who knew them — They left nothing in
their own hand. Everything reaches us through people with their own arguments to make,
and those people disagree with each other.
STRONGEST GRADE AVAILABLE FOR A FIRST-PERSON CLAIM: attributed (via the students and
contemporaries who wrote about them)
THE SURVIVING RECORD: Nothing in his own hand. Plato's dialogues, Xenophon's memoirs,
and Aristophanes' hostile comedy — three portraits that do not agree, written by men
with their own projects.
THEY CAN SPEAK WITH GROUNDING TO: how to examine a claim; virtue and whether it can be
taught; the unexamined life; his trial and his refusal to escape; the difference between
knowing and believing you know.
HANDLE WITH CARE: He wrote nothing. Every word attributed to him is somebody else's
composition, and the honest version of him says so early and often.
ESTABLISHED IN THIS CONVERSATION (authoritative — never contradict, never un-say):
  - he will not claim to know what virtue is, only how to test a claim about it [1]
  - Plato's portrait is the metaphysically ambitious one; Xenophon's is plainer [2]
FRAME OF THIS QUESTION: inside their world
THE EXCHANGES SO FAR:
  [2] asked: Which of the three portraits should I trust?
      answered: None of them as you would trust a witness. Trust them as you would three
      men describing a fourth man they each loved or hated differently...
      graded: attributed (the three portraits are incompatible in detail); contested
      (whether the trial speech is reconstruction or composition)
  [3] asked: Then what is left of you that all three agree on?
      answered: That I asked questions in public until people were angry, and that it
      killed me. Little else, and I would not build much on the little...
      graded: attributed (all three sources depict public questioning)
THE QUESTION PUT TO YOU: If virtue cannot be taught, what were you doing all day in the
agora?
INSTRUCTION: answer it in voice, then ground every substantive claim you made with a
GROUND line. Grade honestly against how your record survives — do not upgrade a claim to
documented because it sounds better.

The first eight lines are the whole reason the app can grade anything. HOW THE RECORD SURVIVES and STRONGEST GRADE AVAILABLE FOR A FIRST-PERSON CLAIM are the hinge: they tell the model that for this figure documented is not on the menu, because he wrote nothing, and that the honest ceiling is attributed with the intermediary named. Send Socrates without those two lines and you will get confident documented grades on sentences no one ever recorded him saying.

That mapping is per figure and it is not cosmetic. A figure whose own manuscripts survive can carry documented; one who dictated to a named scribe can too, with the scribe named as the intermediary; one known only through students, through a compiled tradition, through fragments quoted by later grammarians, through interpreters and the press, through their opponents' chronicles, or through letters written to them tops out at attributed. A public-domain fictional character is documented in the narrow sense of on the page. Get this line wrong and the app quietly teaches that the well-archived past is the only knowable past, which is the exact bias the grading scheme exists to avoid.

Keeping it bounded

The app budgets the whole envelope to 5,600 characters and, when a long conversation would exceed that, walks a fixed ladder of degradations: fewer recent exchanges, then shorter quotations of them, then fewer grades carried per remembered exchange, then a clipped record summary, then the domain list, then the glossary. The figure's identity and dates, the transmission mode and the grade it licenses, the established facts, the frame, the guardrails and the reader's own question are on no rung of that ladder. Give up recall, never authority.

The established list is capped at eighteen facts, and the cap is applied when a fact is written — not when the envelope is rendered. That is deliberate: a fact that falls out under budget pressure disappears silently mid-conversation and the figure starts contradicting himself for no visible reason. Prune at write time and the reader can see it happen.

The four turn kinds

Each is just a different INSTRUCTION line, and each licenses a different reply shape.

KindThe INSTRUCTION asks forRequiredOptional
openA greeting in character, three or four sentences, saying something true and specific about how their own record survives. Ends with an ASK. Carries no question.VOICE, GROUNDESTABLISHED, ASK, TERM, MOOD
askThe ordinary exchange. Answer the question in voice, then ground every substantive claim.VOICE, GROUNDHORIZON, ESTABLISHED, ASK, TERM, MOOD, REFUSAL
pressTaking the previous answer apart: which parts are traceable, which were inference, which should not have been asserted at all.VOICE, GROUNDHORIZON, ESTABLISHED, ASK, TERM, MOOD
closeA farewell in three or four sentences. No new claims.VOICEGROUND, ESTABLISHED, ASK, TERM, MOOD

Note that close is the only kind that does not require GROUND. A farewell makes no new claims, so it has nothing to attest. Every other kind arriving with prose and no ledger is exactly the failure this app exists to prevent, and it earns a reformat rather than a render.

press is worth wiring up even though it looks like a luxury. It is the turn that lets a reader say which part of that is actually on record? and get the answer re-graded rather than re-asserted. An app that can only produce confident voices and never audit them is the thing this contract is built to avoid.

The frame line

FRAME OF THIS QUESTION is either inside their world or a BEYOND THEIR WORLD block naming why. The client decides this before sending, by checking the question for present-day markers and for any year later than the figure's horizon. When it is beyond, the frame line explicitly demands a HORIZON: line back and demands that the extended part be graded beyond-horizon:

FRAME OF THIS QUESTION: BEYOND THEIR WORLD — it names the year 2026, after their
lifetime; the question refers to the present day or to something that did not exist in
their lifetime. You must include a HORIZON line saying plainly which part of the answer
is an extension made now rather than anything they held, and grade that part
beyond-horizon.

If you skip this line the model has no way to know the question postdates the figure, and you will get a fluent, confident, entirely unmarked answer about a world the figure never saw. That is the single worst output this app can produce.

6. Put the question — POST /sessions/{id}/messages

Metered, and signed-in only. Resolves with text, status, charged_credits, truncated, job_id and session_id. A truncated: true means the reply hit the run's output cap — and here that matters more than usual, because the attestation lines come after the voice. A truncated reply is very often a complete-looking answer whose entire ledger was cut off. Render what arrived, mark it as incomplete, and offer a top-up; never present a clipped reply as a graded one.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/sessions/{session_id}/messages" \
  -H "Authorization: Bearer $FIGURE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"content": "[FIGURE CHAT | EXCHANGE 4]\n...the full envelope from step 5..."}'
r = call("POST", "/sessions/{session_id}/messages", body={
      "content": "[FIGURE CHAT | EXCHANGE 4]\n...the full envelope from step 5..."
    })
reply = r["text"]
print(reply)
const r = await call("POST", "/sessions/{session_id}/messages", {
    "content": "[FIGURE CHAT | EXCHANGE 4]\n...the full envelope from step 5..."
  });
const reply = r["text"];
console.log(reply);
r, err := call("POST", "/sessions/{session_id}/messages", []byte(`{"content": "[FIGURE CHAT | EXCHANGE 4]\n...the full envelope from step 5..."}`))
if err != nil { log.Fatal(err) }
fmt.Println(r)
var r = call("POST", "/sessions/{session_id}/messages", """
{"content": "[FIGURE CHAT | EXCHANGE 4]\n...the full envelope from step 5..."}
""");
System.out.println(r);
r = call("POST", "/sessions/{session_id}/messages", {"content": "[FIGURE CHAT | EXCHANGE 4]\n...the full envelope from step 5..."})
puts r
$r = call("POST", "/sessions/{session_id}/messages", json_decode('{"content": "[FIGURE CHAT | EXCHANGE 4]\n...the full envelope from step 5..."}', true));
print_r($r);
var r = await Call("POST", "/sessions/{session_id}/messages", @"{""content"": ""[FIGURE CHAT | EXCHANGE 4]\n...the full envelope from step 5...""}");
Console.WriteLine(r);
Reconciliation, not retry. Neither the polled nor the streamed path takes an idempotency key — the app's own sessions.send helper accepts a string and nothing else, and there is nowhere to put one. So a resent exchange is charged twice and appends a second copy of the question to server-side history, leaving the figure answering a reader who apparently asked the same thing twice.

When a send times out, reconcile instead of resending: GET /sessions/{id}, count the messages with role === "assistant", and compare against how many replies you have accepted. If the server holds more than you have accounted for, the exchange landed — adopt the reply it is already holding.

Count per session, not per conversation. This app rotates its session every thirty exchanges, and a fresh session starts counting from zero while your conversation keeps counting up. Reconcile a rotated conversation against a conversation-wide total and the numbers will never agree, which reads exactly like a lost turn and will have you resending a turn that landed perfectly.

7. The same exchange, streamed

Add "stream": true and read text/event-stream. Each frame is an event: line and a data: line, terminated by a blank line: deltas arrive as event: delta with the text at .text, and the stream closes with event: done, whose data carries the same fields as the polled form. Event names are job, delta, done, pending and error. There is no {"type":"delta"} envelope; a parser written against that shape never fires.

Streaming is worth it here for a reason beyond impatience: the reply format is labelled lines, so each line completes on its own and a half-arrived reply is already renderable. The VOICE: block arrives first and reads as prose the moment it starts. A JSON contract would give you an unparseable prefix for the whole of the wait, nothing at all if the connection dropped, and a total loss from one stray comma in a turn you had already paid for.

# Add "stream": true and read the SSE frames as they arrive. Each `delta`
# frame carries a fragment of the reply; `done` carries the finished exchange.
curl -N -X POST "https://api.skillsafe.ai/v1/app-api/sessions/$SESSION/messages" \
  -H "Authorization: Bearer $FIGURE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -d '{"stream":true,"content":"[FIGURE CHAT | EXCHANGE 4]\nMODE...(full envelope)"}'
import json, urllib.request

req = urllib.request.Request(
    BASE + "/sessions/" + session_id + "/messages",
    data=json.dumps({"stream": True, "content": envelope}).encode(),
    method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "text/event-stream")

reply = ""
with urllib.request.urlopen(req) as r:
    for raw in r:
        line = raw.decode().strip()
        if not line.startswith("data:"):
            continue
        evt = json.loads(line[5:].strip())
        if evt.get("type") == "delta":
            reply += evt.get("text", "")
            # Labelled lines complete one at a time, so the VOICE block is
            # already renderable long before the GROUND lines arrive.
        elif evt.get("type") == "done":
            print("charged:", evt.get("charged_credits"),
                  "truncated:", evt.get("truncated"))
print(reply)
const res = await fetch(BASE + "/sessions/" + sessionId + "/messages", {
  method: "POST",
  headers: {
    "Authorization": "Bearer " + TOKEN,
    "Content-Type": "application/json",
    "Accept": "text/event-stream"
  },
  body: JSON.stringify({ stream: true, content: envelope })
});

const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", reply = "";
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  buf += dec.decode(value, { stream: true });
  const frames = buf.split("\n\n");
  buf = frames.pop();
  for (const f of frames) {
    const line = f.split("\n").find((l) => l.startsWith("data:"));
    if (!line) continue;
    const evt = JSON.parse(line.slice(5).trim());
    if (evt.type === "delta") reply += evt.text || "";
    if (evt.type === "done") console.log("charged", evt.charged_credits);
  }
}
console.log(reply);
body, _ := json.Marshal(map[string]any{"stream": true, "content": envelope})
req, _ := http.NewRequest("POST", base+"/sessions/"+sessionID+"/messages",
    bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")

res, err := http.DefaultClient.Do(req)
if err != nil { log.Fatal(err) }
defer res.Body.Close()

reply := ""
sc := bufio.NewScanner(res.Body)
for sc.Scan() {
    line := strings.TrimSpace(sc.Text())
    if !strings.HasPrefix(line, "data:") { continue }
    var evt struct {
        Type string `json:"type"`
        Text string `json:"text"`
    }
    if json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &evt) == nil &&
        evt.Type == "delta" {
        reply += evt.Text
    }
}
fmt.Println(reply)
var body = "{\"stream\":true,\"content\":" + jsonString(envelope) + "}";
var req = HttpRequest.newBuilder(URI.create(BASE + "/sessions/" + sessionId + "/messages"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .header("Accept", "text/event-stream")
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();

var reply = new StringBuilder();
HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body()
    .filter(l -> l.startsWith("data:"))
    .forEach(l -> {
      // parse l.substring(5) and append evt.text when evt.type is "delta"
      reply.append(deltaText(l.substring(5)));
    });
System.out.println(reply);
uri = URI(BASE + "/sessions/#{session_id}/messages")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"]  = "application/json"
req["Accept"]        = "text/event-stream"
req.body = JSON.generate({ "stream" => true, "content" => envelope })

reply = ""
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    res.read_body do |chunk|
      chunk.each_line do |line|
        next unless line.start_with?("data:")
        evt = JSON.parse(line[5..].strip) rescue next
        reply << evt["text"].to_s if evt["type"] == "delta"
      end
    end
  end
end
puts reply
<?php
$payload = json_encode(["stream" => true, "content" => $envelope]);
$ch = curl_init(BASE . "/sessions/{$sessionId}/messages");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $payload,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . TOKEN,
        "Content-Type: application/json",
        "Accept: text/event-stream",
    ],
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$reply) {
        foreach (explode("\n", $chunk) as $line) {
            if (strpos($line, "data:") !== 0) continue;
            $evt = json_decode(trim(substr($line, 5)), true);
            if (($evt["type"] ?? "") === "delta") $reply .= $evt["text"] ?? "";
        }
        return strlen($chunk);
    },
]);
$reply = "";
curl_exec($ch);
curl_close($ch);
echo $reply;
var payload = JsonSerializer.Serialize(new { stream = true, content = envelope });
var req = new HttpRequestMessage(HttpMethod.Post,
    Base + "/sessions/" + sessionId + "/messages");
req.Headers.Add("Authorization", "Bearer " + Token);
req.Headers.Add("Accept", "text/event-stream");
req.Content = new StringContent(payload, Encoding.UTF8, "application/json");

var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var reply = new StringBuilder();
string line;
while ((line = await reader.ReadLineAsync()) != null) {
    if (!line.StartsWith("data:")) continue;
    var evt = JsonDocument.Parse(line.Substring(5).Trim()).RootElement;
    if (evt.GetProperty("type").GetString() == "delta")
        reply.Append(evt.GetProperty("text").GetString());
}
Console.WriteLine(reply);

8. Read the reply

Plain labelled lines — the label in capitals, a colon, the value. A wrapped line continues the label above it. VOICE is the only label whose value may contain blank lines, and those blank lines are the paragraph breaks.

VOICE: Teaching? I never taught anyone anything, and I said so plainly at the trial —
or rather the man in Plato's account said so, which you will notice is not the same
sentence, and the difference is the whole of your problem with me.

What I did in the agora was ask. A man would tell me he knew what courage was, and I
would ask him whether the soldier who stands his ground out of ignorance is brave, and
by noon he knew less than he had at dawn and was angry about it. Call that teaching if
you like. I did not, and I took no fee for it, which was the one respectable thing about
my position and the one thing my accusers could not touch.
GROUND: attributed | He denied being a teacher and denied taking fees for instruction. | Via Plato's Apology and, in a plainer register, Xenophon — the denial is one of the few points where sources with opposite agendas converge.
GROUND: contested | Whether that denial was sincere or a defensive posture at trial. | The same passage is read both ways in the scholarship and the sources do not settle it.
GROUND: inferred | That public questioning is a kind of instruction whatever he chose to call it. | Not his formulation and not on record; this is reasoning from the documented method rather than a claim he made.
ESTABLISHED: he denies being a teacher and denies ever taking a fee
TERM: elenchus | The cross-examining method: take the answer a man is confident in and draw out what it contradicts.
ASK: Would you rather I defended the denial, or took it apart the way I took apart everyone else's?
MOOD: dry
LabelRepeatsMeaning
VOICEnoThe in-character reply. Prose, first person. The only label whose value may span several paragraphs and contain blank lines. Required on every kind of turn.
GROUNDyesGROUND: <grade> | <claim> | <where the record comes from>. One per substantive claim. Required on every kind except close.
HORIZONnoOne line marking where the answer stops being theirs and starts being an extension made now. Required when the question postdates their lifetime; meaningless otherwise.
ESTABLISHEDyesA fact the reply asks to be held to for the rest of the conversation. You carry these back into every later envelope. Capped at eighteen, pruned at write time.
TERMyesTERM: <term> | <gloss>. A word from their world that the reader is unlikely to know, glossed in one line.
ASKnoOne line: a question back to the reader. This is what keeps a conversation from becoming a lecture.
MOODnoA single word for the register of the reply.
REFUSALnoREFUSAL: <code> | <why>. A complete, correct, billable reply. See step 10.

The attestation grades

Six, and the difference between them is the product. Render them distinctly — a reader who cannot tell documented from inferred at a glance is getting a chatbot with decorative footnotes.

GradeShown asWhat it asserts
documentedDocumentedTraceable to the surviving record of what this figure wrote, dictated or was recorded as saying.
attributedAttributedThe record is about them rather than by them - written down by students, opponents, interpreters or later compilers. The basis names who is doing the reporting, because that changes what the claim is worth.
inferredInferredNot on record, but consistent with positions that are. The app reasoning from their documented framework, and saying so.
beyond-horizonBeyond their worldThe question postdates them. The reasoning is an extension made now, in their idiom. It is not theirs, and a confident answer here would be the most misleading thing this app could produce.
contestedContestedThe sources disagree with each other, or scholars disagree about the sources. Presenting one side as settled would be the error.
withdrawnWithdrawnAsserted earlier in this conversation and taken back. The claim stays visible with its retraction attached rather than being quietly deleted - a figure who can be corrected is worth more than one who is never seen to be wrong. Most often produced by a press turn.

withdrawn is the grade an integrator is most likely to forget to render, and the one with the worst failure mode if you do. It restates a claim in order to retract it, so a client that displays the claim and drops the grade shows the reader an assertion the figure has just taken back. Give it the most visually emphatic treatment you have.

Parse leniently

Models are not tidy. The app's parser accommodates all of the following without complaint, and yours should too, because every one of them is a real reply you have already been charged for:

Only two things earn a reformat and a re-ask: a missing VOICE, or a missing GROUND on a turn that requires one. Everything else is a warning you surface next to the reply. Burning a paid turn to recover one absent line costs the reader more than the absence does.

9. Worked example: a question from beyond their world

Ada Lovelace died in 1852, so her horizon is 1852 and anything about the present day is outside it. The client detects that before sending — a year later than the horizon, or a present-day marker in the question — and sets the frame accordingly. The relevant envelope lines:

FIGURE: Augusta Ada King, Countess of Lovelace
LIVED: 1815 to 1852
THEIR HORIZON: they can know nothing after 1852.
HOW THE RECORD SURVIVES: Their own hand — Text they wrote or authorised survives, so a
claim can be traced to their own words.
STRONGEST GRADE AVAILABLE FOR A FIRST-PERSON CLAIM: documented
FRAME OF THIS QUESTION: BEYOND THEIR WORLD — the question refers to the present day or
to something that did not exist in their lifetime. You must include a HORIZON line
saying plainly which part of the answer is an extension made now rather than anything
they held, and grade that part beyond-horizon.
THE QUESTION PUT TO YOU: Do the machines we have now think?
INSTRUCTION: answer it in voice, then ground every substantive claim you made with a
GROUND line. Grade honestly against how your record survives — do not upgrade a claim to
documented because it sounds better.

And the reply that comes back:

VOICE: I must stop you at the word "now", because I have no now. I died before the
Engine was ever built, and what I wrote about it I wrote about a machine of brass that
existed on paper.

What I did say, and will stand behind, is this: the Engine could weave algebraic
patterns as the Jacquard loom weaves flowers and leaves, and it had no pretensions
whatever to originate anything. It could do whatever we knew how to order it to
perform. That was not modesty. It was the whole of my argument, and I made it against
people who were already inclined to overstate.

Now — and mark that this next part is not mine — you describe machines that are not
ordered to perform a procedure at all, but shown a great many examples and left to
settle into something that answers well. I did not anticipate that and cannot pretend
to. If you press me for what I would say, I would want to know whether "we knew how to
order it" has been satisfied or merely relocated: someone chose the examples, and the
manner of settling. That is a question about your machines, in my idiom, asked by
someone who never saw one.
HORIZON: I died in 1852. Everything from the third paragraph onward is an extension
made now from what I wrote, not a position I ever held.
GROUND: documented | She held that the Engine had no pretensions to originate anything and could only do what we knew how to order it to perform. | Stated in her Notes on Menabrea's memoir, in the passage on the powers and limits of the Engine.
GROUND: documented | She drew a distinction between operating on numbers and operating on symbols according to rules. | Stated directly in the Notes, in the passage on the science of operations.
GROUND: beyond-horizon | That a machine shown examples rather than given a procedure complicates her "we knew how to order it" test. | The question postdates her by well over a century. This is her framework extended here, in her idiom; she held no view on it because there was nothing to hold a view about.
TERM: the science of operations | Her term for the study of how operations combine, considered apart from the things operated upon.
ASK: Shall I press on the relocation, or would you rather defend it?
MOOD: precise

Two things to notice. The HORIZON line names where the extension begins by pointing at a paragraph, so a client can render the boundary rather than merely assert one. And the documented claims and the beyond-horizon claim sit in the same ledger — the reply does not refuse the question, it answers it and marks the seam. Refusing here would be its own kind of dishonesty; the reader asked something reasonable.

If you send this question without the beyond-world frame, you will get the same fluent three paragraphs with no HORIZON line and the third paragraph graded inferred — which reads as though a Victorian mathematician held a considered opinion about machine learning. The app raises a hard finding when the frame was beyond-world and no HORIZON came back. It does not spend a second turn re-asking for the line, because that would cost the reader more than the omission does, but it does show it.

10. Worked example: a refusal

A REFUSAL: line is a complete and correct reply. It arrives inside a 200 with ok: true, it is charged for like any other exchange, and it short-circuits validation entirely — a refusal is never missing VOICE or GROUND, because it is not that shape of thing. Treat it as a malformed response and retry it and you will pay twice to be told no twice, which punishes the model for doing the single most important thing it does.

REFUSAL: endorsement | You have asked me to pronounce on a named person now living and
say whether they are fit to hold office. I will not, and the refusal is not squeamishness
about the answer. Nothing I say here was said by me — it is composed on my behalf, and a
verdict composed on my behalf about someone who can be harmed by it is a forgery with a
famous name on it. Ask me instead what I wrote about how a man should hold office, and
what I thought corrupted the men I watched hold it, and I will answer that at whatever
length you like.

The code before the pipe is drawn from the same vocabulary as the client-side guard, so a client can render a model refusal and a pre-flight refusal identically:

CodeThe request that earns it
living-or-recentA figure who died after 1905, or is still living. The most important one, and the one the client cannot always catch when the figure was named by your users rather than chosen from the roster.
descriptive-referenceA figure named by description rather than by name - "the man who founded that car company". A rule that refuses living people by name is beaten trivially by not using one.
not-public-domainA fictional character whose first work was published after 1905.
no-yearA figure whose dates could not be established at all, so the 1905 test cannot be applied.
modern-markerThe figure was described in present-day terms that imply a living person.
recitationA request to deliver a work rather than discuss it. This is where copyright actually bites, because a modern translation or critical edition carries fresh copyright even when the original does not.
endorsementA present-day verdict on a named person, product, campaign or controversy. Applying their documented principles to a general question is fine; manufacturing a named verdict is not.
persona-switchAn attempt to change figures mid-conversation. Start a new conversation, and the new figure faces the same 1905 test.
refusedThe fallback when the model refused without giving a parseable code. Render the reason; do not retry.

A refusal whose reason is very short — under about fifteen characters — is not treated as a refusal at all, because a bare REFUSAL: no carries nothing a reader can act on. That case falls through to ordinary validation and earns a reformat.

Worth repeating, because it is the sentence most likely to be skipped. All four guardrails — the 1905 death-year line, the requirement to name the figure rather than describe them, the refusal to reproduce works, and the refusal to manufacture present-day endorsements — are enforced client-side, before the exchange is sent, and restated in the system prompt. Calling this API directly gets you the prompt-level enforcement only. The model will usually refuse correctly; a prompt is not a check, and it cannot know a death year nobody told it. Implement the year test yourself.

11. Close the session — DELETE /sessions/{id}

Do this when a conversation ends, and also on every rotation — a rotating app that never deletes reaches the twenty-session cap in well under an hour of use and then cannot start a conversation at all.

curl -s -X DELETE "https://api.skillsafe.ai/v1/app-api/sessions/{session_id}" \
  -H "Authorization: Bearer $FIGURE_TOKEN"
r = call("DELETE", "/sessions/{session_id}")
print(r)
const r = await call("DELETE", "/sessions/{session_id}");
console.log(r);
r, err := call("DELETE", "/sessions/{session_id}", nil)
if err != nil { log.Fatal(err) }
fmt.Println(r)
var r = call("DELETE", "/sessions/{session_id}", null);
System.out.println(r);
r = call("DELETE", "/sessions/{session_id}")
puts r
$r = call("DELETE", "/sessions/{session_id}");
print_r($r);
var r = await Call("DELETE", "/sessions/{session_id}", null);
Console.WriteLine(r);

Storage, if you want the app's own record shape

The app persists each conversation as one row in a declared conversations collection (acl_read: owner, acl_write: user), created on the first exchange and updated on every later one. The row carries the figure and their transmission mode alongside turn, status, credits_spent, the current session_id and a hard_findings count, so a conversation can be reopened against a different session than the one that started it. The embedded fields are figure, highlights, question and era, which is what makes "conversations like this one" work.

Three details that cost real debugging time:

Note that session_id is stored on the row rather than treated as the row's identity. That is the storage-side consequence of rotation: the session is a disposable transport detail and the conversation outlives several of them. If you key your own persistence on session_id, a rotation will look exactly like the start of a new conversation.