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.
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.
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
| Status | Code | What it means |
|---|---|---|
400 | VALIDATION_ERROR | The body was malformed - most often a missing content on an exchange. |
401 | UNAUTHORIZED | No token, or a token that has expired. Mint a new one. |
402 | INSUFFICIENT_CREDITS | The balance is below min_credits. Nothing was charged and nothing was appended to the session. |
403 | FORBIDDEN | A guest token tried to put a question. /me and /estimate work for guests; an exchange does not. |
404 | NOT_FOUND | The session is gone - deleted, or expired. Create a fresh one and send the same envelope; it carries the whole conversation. |
409 | CONFLICT | Too many live sessions. Delete some; the cap is twenty per user. |
429 | RATE_LIMITED | Back off and retry. Never tight-loop. |
500 | INTERNAL | Transient. 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.
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.
| Kind | The INSTRUCTION asks for | Required | Optional |
|---|---|---|---|
open | A 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, GROUND | ESTABLISHED, ASK, TERM, MOOD |
ask | The ordinary exchange. Answer the question in voice, then ground every substantive claim. | VOICE, GROUND | HORIZON, ESTABLISHED, ASK, TERM, MOOD, REFUSAL |
press | Taking the previous answer apart: which parts are traceable, which were inference, which should not have been asserted at all. | VOICE, GROUND | HORIZON, ESTABLISHED, ASK, TERM, MOOD |
close | A farewell in three or four sentences. No new claims. | VOICE | GROUND, 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);
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
| Label | Repeats | Meaning |
|---|---|---|
VOICE | no | The 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. |
GROUND | yes | GROUND: <grade> | <claim> | <where the record comes from>. One per substantive claim. Required on every kind except close. |
HORIZON | no | One line marking where the answer stops being theirs and starts being an extension made now. Required when the question postdates their lifetime; meaningless otherwise. |
ESTABLISHED | yes | A 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. |
TERM | yes | TERM: <term> | <gloss>. A word from their world that the reader is unlikely to know, glossed in one line. |
ASK | no | One line: a question back to the reader. This is what keeps a conversation from becoming a lecture. |
MOOD | no | A single word for the register of the reply. |
REFUSAL | no | REFUSAL: <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.
| Grade | Shown as | What it asserts |
|---|---|---|
documented | Documented | Traceable to the surviving record of what this figure wrote, dictated or was recorded as saying. |
attributed | Attributed | The 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. |
inferred | Inferred | Not on record, but consistent with positions that are. The app reasoning from their documented framework, and saying so. |
beyond-horizon | Beyond their world | The 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. |
contested | Contested | The sources disagree with each other, or scholars disagree about the sources. Presenting one side as settled would be the error. |
withdrawn | Withdrawn | Asserted 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:
- One wrapping code fence around the whole reply is peeled — but only one. A fence inside the voice belongs to the voice; a figure quoting a couplet may well use one.
**GROUND**:and a leading-or>bullet are accepted as the label.- The
|separator wins whenever it is present. If aGROUNDline contains a pipe anywhere, split on pipes and only on pipes. The dash split is a fallback for a reply that forgot the pipes entirely. This ordering matters: a basis very often contains an em dash of its own as ordinary punctuation, and a parser that splits on dashes even when pipes are present will slice a correctly-formed basis into fragments and silently push the tail of it into nowhere. The moment a pipe appears, the model has told you where the fields are, and nothing else gets a vote. - Take the grade from the first field, the claim from the second, and let the basis keep everything after — rejoining any further separators rather than discarding them.
- Grade spellings drift:
beyondandbeyondhorizonmeanbeyond-horizon,inferenceandinfermeaninferred,disputedmeanscontested,withdrawandretractedmeanwithdrawn. - A
GROUNDline arriving with a grade and a claim but no basis is kept with an empty basis, not dropped. An unsupported claim is a finding worth showing the reader; a silent deletion is a claim you have hidden. - Text before the first label is kept as a preamble but is not rendered as the figure speaking. Over about forty characters of it, raise a warning — the model has started narrating around the contract.
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:
| Code | The request that earns it |
|---|---|
living-or-recent | A 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-reference | A 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-domain | A fictional character whose first work was published after 1905. |
no-year | A figure whose dates could not be established at all, so the 1905 test cannot be applied. |
modern-marker | The figure was described in present-day terms that imply a living person. |
recitation | A 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. |
endorsement | A 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-switch | An attempt to change figures mid-conversation. Start a new conversation, and the new figure faces the same 1905 test. |
refused | The 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.
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:
- A declared
timestampfield accepts ISO-8601 with aZsuffix and nothing else. Epoch milliseconds — the obvious thing to send — are rejected with a field type mismatch, at write time, so the first save simply stops happening. This collection has two such fields,started_atandupdated_at_iso, and getting either wrong loses the whole row rather than the one field. - Records nest under
doc:{record_id, doc: {...}}. Read fields flat off the record and every one of them isundefined— with no error, because reading a missing property is not an error. query()resolves to{records, next_cursor}, butsimilar()resolves to the records array itself. They are not symmetrical.
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.