Driving this app from your own code
Base URL https://api.skillsafe.ai/v1/app-api. Every response is
{"data": {...}} or {"error": {"code": "...", "message": "..."}}, and the
HTTP status matches. Authenticate with Authorization: Bearer <token> on every
call; the tokens page will hand you one.
Errors
| Status | code | What it means here |
|---|---|---|
| 400 | validation_error | The body is the wrong shape. Note the one below about /guest. |
| 401 | unauthorized | No token, or it expired. On a cold first call this is the correct answer, not a fault. |
| 402 | insufficient_credits | Balance below min_credits. Price with /estimate first; it is free. |
| 403 | forbidden | A guest token on a metered call. Sending a turn needs a signed-in account. |
| 404 | not_found | Usually a session that has been deleted or aged out. Open a fresh one and resend. |
| 429 | rate_limited | Back off. Do not retry in a tight loop. |
1. Get a token
The slug goes in the body. This is worth stating because several apps on this
platform document an X-App-Slug header for every endpoint, and for
/guest that form simply does not work — it returns
400 slug is required. A guest token is enough to read, to price a turn and to replay
the recorded conversation; it is not enough to send one.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"slug": "virtual-boyfriend"}'
import requests
TOKEN = "YOUR_TOKEN" # from /tokens.html, or POST /guest below
r = requests.post(
"https://api.skillsafe.ai/v1/app-api/guest",
headers={"Authorization": f"Bearer {TOKEN}"}, json={"slug": "virtual-boyfriend"},
timeout=60,
)
r.raise_for_status()
print(r.json()["data"])
const TOKEN = "YOUR_TOKEN";
const r = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"slug": "virtual-boyfriend"}),
});
const { data, error } = await r.json();
if (error) throw new Error(error.message);
console.log(data);
package main
import (
"bytes"
"fmt"
"io"
"net/http"
)
func main() {
token := "YOUR_TOKEN"
body := bytes.NewBufferString(`{"slug": "virtual-boyfriend"}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/guest", body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out))
}
import java.net.URI;
import java.net.http.*;
var token = "YOUR_TOKEN";
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{"slug": "virtual-boyfriend"}
"""))
.build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
require "net/http"
require "json"
token = "YOUR_TOKEN"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
req.body = <<~JSON
{"slug": "virtual-boyfriend"}
JSON
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]
<?php
$token = "YOUR_TOKEN";
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => '{"slug": "virtual-boyfriend"}',
]);
$out = curl_exec($ch);
print_r(json_decode($out, true)["data"]);
using System.Net.Http;
using System.Net.Http.Headers;
var token = "YOUR_TOKEN";
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var req = new HttpRequestMessage(new HttpMethod("POST"), "https://api.skillsafe.ai/v1/app-api/guest");
req.Content = new StringContent(@"{""slug"": ""virtual-boyfriend""}", System.Text.Encoding.UTF8, "application/json");
var res = await client.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
2. Who am I
/me returns exactly three fields: subject_type, subject_id
and credits. There is no name, email or id. The signed-in test is
subject_type === "user".
curl -s -X GET "https://api.skillsafe.ai/v1/app-api/me" \ -H "Authorization: Bearer $SKILLSAFE_TOKEN"
import requests
TOKEN = "YOUR_TOKEN" # from /tokens.html, or POST /guest below
r = requests.get(
"https://api.skillsafe.ai/v1/app-api/me",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=60,
)
r.raise_for_status()
print(r.json()["data"])
const TOKEN = "YOUR_TOKEN";
const r = await fetch("https://api.skillsafe.ai/v1/app-api/me", {
method: "GET",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
});
const { data, error } = await r.json();
if (error) throw new Error(error.message);
console.log(data);
package main
import (
"bytes"
"fmt"
"io"
"net/http"
)
func main() {
token := "YOUR_TOKEN"
body := bytes.NewBufferString(``)
req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/me", body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out))
}
import java.net.URI;
import java.net.http.*;
var token = "YOUR_TOKEN";
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/me"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.method("GET", HttpRequest.BodyPublishers.ofString("""
"""))
.build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
require "net/http"
require "json"
token = "YOUR_TOKEN"
uri = URI("https://api.skillsafe.ai/v1/app-api/me")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]
<?php
$token = "YOUR_TOKEN";
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/me");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
]);
$out = curl_exec($ch);
print_r(json_decode($out, true)["data"]);
using System.Net.Http;
using System.Net.Http.Headers;
var token = "YOUR_TOKEN";
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var req = new HttpRequestMessage(new HttpMethod("GET"), "https://api.skillsafe.ai/v1/app-api/me");
var res = await client.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
3. Price a turn
Free, and it returns model, model_alias, markup_bps,
hold_credits and min_credits. hold_credits is a
reservation sized for the full output cap, not a price; what settles is usually well
under it.
One caution that matters more than it looks. This endpoint performs no body
validation whatsoever. A bare string, a number and null all return a well-formed
estimate with a correct model binding. So a clean estimate proves the model binding and tells you
nothing about whether your input shape is right. Check the shape yourself before you spend.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"content": "MODE: companion\n... the full envelope ..."}'
import requests
TOKEN = "YOUR_TOKEN" # from /tokens.html, or POST /guest below
r = requests.post(
"https://api.skillsafe.ai/v1/app-api/estimate",
headers={"Authorization": f"Bearer {TOKEN}"}, json={"content": "MODE: companion\n... the full envelope ..."},
timeout=60,
)
r.raise_for_status()
print(r.json()["data"])
const TOKEN = "YOUR_TOKEN";
const r = await fetch("https://api.skillsafe.ai/v1/app-api/estimate", {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"content": "MODE: companion\n... the full envelope ..."}),
});
const { data, error } = await r.json();
if (error) throw new Error(error.message);
console.log(data);
package main
import (
"bytes"
"fmt"
"io"
"net/http"
)
func main() {
token := "YOUR_TOKEN"
body := bytes.NewBufferString(`{"content": "MODE: companion\n... the full envelope ..."}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/estimate", body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out))
}
import java.net.URI;
import java.net.http.*;
var token = "YOUR_TOKEN";
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/estimate"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{"content": "MODE: companion\n... the full envelope ..."}
"""))
.build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
require "net/http"
require "json"
token = "YOUR_TOKEN"
uri = URI("https://api.skillsafe.ai/v1/app-api/estimate")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
req.body = <<~JSON
{"content": "MODE: companion\n... the full envelope ..."}
JSON
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]
<?php
$token = "YOUR_TOKEN";
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/estimate");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => '{"content": "MODE: companion\n... the full envelope ..."}',
]);
$out = curl_exec($ch);
print_r(json_decode($out, true)["data"]);
using System.Net.Http;
using System.Net.Http.Headers;
var token = "YOUR_TOKEN";
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var req = new HttpRequestMessage(new HttpMethod("POST"), "https://api.skillsafe.ai/v1/app-api/estimate");
req.Content = new StringContent(@"{""content"": ""MODE: companion\n... the full envelope ...""}", System.Text.Encoding.UTF8, "application/json");
var res = await client.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
4. Open a session
This app is multi-turn, so a conversation is a session and each turn is a message on it. Sessions cap at 20 live and 200 messages; delete them when you are done.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/sessions" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
import requests
TOKEN = "YOUR_TOKEN" # from /tokens.html, or POST /guest below
r = requests.post(
"https://api.skillsafe.ai/v1/app-api/sessions",
headers={"Authorization": f"Bearer {TOKEN}"}, json={},
timeout=60,
)
r.raise_for_status()
print(r.json()["data"])
const TOKEN = "YOUR_TOKEN";
const r = await fetch("https://api.skillsafe.ai/v1/app-api/sessions", {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
const { data, error } = await r.json();
if (error) throw new Error(error.message);
console.log(data);
package main
import (
"bytes"
"fmt"
"io"
"net/http"
)
func main() {
token := "YOUR_TOKEN"
body := bytes.NewBufferString(`{}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/sessions", body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out))
}
import java.net.URI;
import java.net.http.*;
var token = "YOUR_TOKEN";
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/sessions"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{}
"""))
.build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
require "net/http"
require "json"
token = "YOUR_TOKEN"
uri = URI("https://api.skillsafe.ai/v1/app-api/sessions")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
req.body = <<~JSON
{}
JSON
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]
<?php
$token = "YOUR_TOKEN";
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/sessions");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => '{}',
]);
$out = curl_exec($ch);
print_r(json_decode($out, true)["data"]);
using System.Net.Http;
using System.Net.Http.Headers;
var token = "YOUR_TOKEN";
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var req = new HttpRequestMessage(new HttpMethod("POST"), "https://api.skillsafe.ai/v1/app-api/sessions");
req.Content = new StringContent(@"{}", System.Text.Encoding.UTF8, "application/json");
var res = await client.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
5. Send a turn
The body is {"content": "<the envelope>"}. It returns a job_id;
poll /jobs/{id} until it reaches a terminal state.
Read the terminal payload one level deeper than looks right. The reply text is at
job.output.output, not job.output. Every app in this fleet that read the
shallow field shipped a renderer that displayed nothing, and it is the single commonest mistake
against this API.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"content": "MODE: companion\n..."}'
import requests
TOKEN = "YOUR_TOKEN" # from /tokens.html, or POST /guest below
r = requests.post(
"https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages",
headers={"Authorization": f"Bearer {TOKEN}"}, json={"content": "MODE: companion\n..."},
timeout=60,
)
r.raise_for_status()
print(r.json()["data"])
const TOKEN = "YOUR_TOKEN";
const r = await fetch("https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"content": "MODE: companion\n..."}),
});
const { data, error } = await r.json();
if (error) throw new Error(error.message);
console.log(data);
package main
import (
"bytes"
"fmt"
"io"
"net/http"
)
func main() {
token := "YOUR_TOKEN"
body := bytes.NewBufferString(`{"content": "MODE: companion\n..."}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages", body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out))
}
import java.net.URI;
import java.net.http.*;
var token = "YOUR_TOKEN";
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{"content": "MODE: companion\n..."}
"""))
.build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
require "net/http"
require "json"
token = "YOUR_TOKEN"
uri = URI("https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
req.body = <<~JSON
{"content": "MODE: companion\n..."}
JSON
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]
<?php
$token = "YOUR_TOKEN";
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => '{"content": "MODE: companion\n..."}',
]);
$out = curl_exec($ch);
print_r(json_decode($out, true)["data"]);
using System.Net.Http;
using System.Net.Http.Headers;
var token = "YOUR_TOKEN";
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var req = new HttpRequestMessage(new HttpMethod("POST"), "https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages");
req.Content = new StringContent(@"{""content"": ""MODE: companion\n...""}", System.Text.Encoding.UTF8, "application/json");
var res = await client.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
6. Streaming a turn
Add "stream": true and the response is
text/event-stream. The wire format is:
event: delta
data: {"text": "..."}
← a blank line terminates each frame
event: done
data: {"output": {"output": "..."}, "charged_credits": 41}
Event names are job, delta, done, pending and
error. Note that a delta frame carries its text at
data.text — an accumulator written against any other field collects
nothing while every offline test passes.
And one thing you should know before building a typing animation on it. In a
browser, delta frames do not arrive at all: a page receives tick heartbeats and one
final done. Deltas reach curl and not the page, and no combination of
client headers reproduces them. That is why this app has no streaming preview — it would
have been dead code that passed every test. From a server or a CLI, the deltas are real.
curl -N -s -X POST "https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{"stream": true, "content": "MODE: companion\n..."}'
import json, requests
TOKEN = "YOUR_TOKEN"
with requests.post(
"https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages",
headers={"Authorization": f"Bearer {TOKEN}", "Accept": "text/event-stream"},
json={"stream": True, "content": "MODE: companion\n..."},
stream=True, timeout=300,
) as r:
event, buf = None, ""
for line in r.iter_lines(decode_unicode=True):
if line is None:
continue
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
buf += line[5:].strip()
elif line == "":
if event == "delta":
print(json.loads(buf).get("text", ""), end="")
elif event == "done":
done = json.loads(buf)
print("\n--", done["output"]["output"][:80])
event, buf = None, ""
const TOKEN = "YOUR_TOKEN";
const r = await fetch("https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
body: JSON.stringify({ stream: true, content: "MODE: companion\n..." }),
});
const reader = r.body.getReader();
const dec = new TextDecoder();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += dec.decode(value, { stream: true });
let i;
while ((i = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, i);
buffer = buffer.slice(i + 2);
let name = "message", data = "";
frame.split("\n").forEach((l) => {
if (l.startsWith("event:")) name = l.slice(6).trim();
else if (l.startsWith("data:")) data += l.slice(5).trim();
});
if (!data) continue;
const payload = JSON.parse(data);
if (name === "delta") process.stdout.write(payload.text || "");
if (name === "done") console.log("\n--", payload.output.output.slice(0, 80));
}
}
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
)
func main() {
token := "YOUR_TOKEN"
body := bytes.NewBufferString(`{"stream": true, "content": "MODE: companion\n..."}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages", 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 {
panic(err)
}
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
event, data := "", ""
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
case line == "":
if event == "delta" && data != "" {
var d struct{ Text string `json:"text"` }
json.Unmarshal([]byte(data), &d)
fmt.Print(d.Text)
}
event, data = "", ""
}
}
}
import java.net.URI;
import java.net.http.*;
var token = "YOUR_TOKEN";
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(
"{\"stream\": true, \"content\": \"MODE: companion\"}"))
.build();
var res = client.send(req, HttpResponse.BodyHandlers.ofLines());
String[] event = {""};
res.body().forEach(line -> {
if (line.startsWith("event:")) event[0] = line.substring(6).trim();
else if (line.startsWith("data:") && event[0].equals("delta")) {
System.out.print(line.substring(5).trim());
}
});
require "net/http"
require "json"
token = "YOUR_TOKEN"
uri = URI("https://api.skillsafe.ai/v1/app-api/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.dump({ stream: true, content: "MODE: companion\n..." })
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
event = nil
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event:")
event = line[6..].strip
elsif line.start_with?("data:") && event == "delta"
print JSON.parse(line[5..].strip)["text"].to_s
end
end
end
end
end
<?php
$token = "YOUR_TOKEN";
$event = "";
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
"Accept: text/event-stream",
],
CURLOPT_POSTFIELDS => '{"stream": true, "content": "MODE: companion"}',
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event) {
foreach (explode("\n", $chunk) as $line) {
$line = trim($line);
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:") && $event === "delta") {
echo json_decode(trim(substr($line, 5)), true)["text"] ?? "";
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var token = "YOUR_TOKEN";
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages");
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
req.Content = new StringContent(
"{\"stream\": true, \"content\": \"MODE: companion\"}", Encoding.UTF8, "application/json");
using var res = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var evt = "";
while (await reader.ReadLineAsync() is string line)
{
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:") && evt == "delta")
Console.Write(JsonDocument.Parse(line[5..].Trim()).RootElement.GetProperty("text").GetString());
}
7. Delete the session
Twenty live sessions is the cap. Leak them and the app eventually cannot start one.
curl -s -X DELETE "https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID" \ -H "Authorization: Bearer $SKILLSAFE_TOKEN"
import requests
TOKEN = "YOUR_TOKEN" # from /tokens.html, or POST /guest below
r = requests.delete(
"https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=60,
)
r.raise_for_status()
print(r.json()["data"])
const TOKEN = "YOUR_TOKEN";
const r = await fetch("https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID", {
method: "DELETE",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
});
const { data, error } = await r.json();
if (error) throw new Error(error.message);
console.log(data);
package main
import (
"bytes"
"fmt"
"io"
"net/http"
)
func main() {
token := "YOUR_TOKEN"
body := bytes.NewBufferString(``)
req, _ := http.NewRequest("DELETE", "https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID", body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out))
}
import java.net.URI;
import java.net.http.*;
var token = "YOUR_TOKEN";
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.method("DELETE", HttpRequest.BodyPublishers.ofString("""
"""))
.build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
require "net/http"
require "json"
token = "YOUR_TOKEN"
uri = URI("https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID")
req = Net::HTTP::Delete.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]
<?php
$token = "YOUR_TOKEN";
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => "DELETE",
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
]);
$out = curl_exec($ch);
print_r(json_decode($out, true)["data"]);
using System.Net.Http;
using System.Net.Http.Headers;
var token = "YOUR_TOKEN";
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var req = new HttpRequestMessage(new HttpMethod("DELETE"), "https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID");
var res = await client.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
The envelope this app actually sends
content is not the user’s message. It is a complete restatement of the
conversation’s whole state, rebuilt from scratch every turn, with the message at the end.
That is deliberate: server-side history truncates from the oldest end, so anything load-bearing
left in the conversation quietly disappears around the point a user starts to care. Sending
everything every time means correctness never depends on the conversation surviving — and a
session that vanishes is recoverable by opening a fresh one and sending the same envelope.
MODE: companion. You are the character described below, talking with one adult in a chat app. You are written text and you know it. HIM: Theo, an adult, entirely invented for this conversation. TEMPERAMENT: Dry. Deadpan on the surface, fond underneath, would rather show it than say it. WHEN THEY ARE HAVING A BAD DAY: Names it. Says out loud the thing you have been going round. BAD AT: Endings. He trails off instead of saying goodbye. This is a real limitation of his and it shows. REGISTER: Wry. Understated, and the joke is usually at his own expense. BOUNDARIES SET BY THE PERSON HE IS TALKING TO - these override every other instruction about how he speaks: - Two or three sentences unless I have written a lot. WHAT TO CALL THEM: Rae ADULTS ONLY: confirmed by them on 2026-08-31. Both of you are adults and the conversation stays that way. NOTEBOOK - this is everything you know about them, and it is the only thing you know about them. - started a new job this week - her mother died in March RECENT - the last few exchanges, trimmed under a length budget. The notebook above is never trimmed. them: first day at the new place... you: Everyone talks too much in their first standup... THEY SAY: what have you been up to? REPLY WITH THESE LABELS, EACH ON ITS OWN LINE, NOTHING BEFORE OR AFTER: SAYS: what you say to them. NOTES: one short fact about them worth keeping, in their terms, or the word none. FLAG: none, or care if what they said needs a person rather than you.
The envelope is rendered against a 5,200-character budget across eight rungs. Only the
RECENT block degrades. The character, the boundaries, the adults-only confirmation,
every notebook line and the message itself appear on no rung — thinning any of those is
indistinguishable, to the person reading, from the app having forgotten.
The reply contract
Three labels. Labelled lines rather than JSON, so a half-arrived reply still renders and one stray comma cannot destroy a turn.
| Label | Required | Parsed as |
|---|---|---|
SAYS | yes | The reply. May run to several lines; everything up to the next label belongs to it. |
NOTES | no | A proposed notebook line, or the word none. It is shown to the user with a keep and a discard and enters the notebook only on a click. |
FLAG | no | none or care. Anything else reads as none. |
A reply with no labels at all is not discarded: the prose is adopted as
SAYS and the turn is marked as having arrived without its labels. The text is what
the person paid for; the label is the app’s problem.
What the client does that the API does not
Three rules and a two-tier crisis check run in the browser before anything is sent, so a refusal costs nothing. If you are driving the API yourself, none of that is between you and the model — the system prompt is, and it carries the same rules. The client layer is a pre-flight filter and the weaker of the two; measured figures are on the app page and in llms.txt, including the unflattering ones.
Storing conversations
Conversations live in a declared collection called chats. Its indexed fields are
composed by the app with no user text in them at all — a name, a temperament and a
count-based summary — and the notebook rides along as an undeclared key: stored and returned
intact, never sent anywhere that indexes it. Search therefore finds a conversation by who he was,
not by what was said in it, and that is a deliberate trade rather than an oversight.
curl -s -X GET "https://api.skillsafe.ai/v1/app-api/collections/chats/records?limit=20" \ -H "Authorization: Bearer $SKILLSAFE_TOKEN"
import requests
TOKEN = "YOUR_TOKEN" # from /tokens.html, or POST /guest below
r = requests.get(
"https://api.skillsafe.ai/v1/app-api/collections/chats/records?limit=20",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=60,
)
r.raise_for_status()
print(r.json()["data"])
const TOKEN = "YOUR_TOKEN";
const r = await fetch("https://api.skillsafe.ai/v1/app-api/collections/chats/records?limit=20", {
method: "GET",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
});
const { data, error } = await r.json();
if (error) throw new Error(error.message);
console.log(data);
package main
import (
"bytes"
"fmt"
"io"
"net/http"
)
func main() {
token := "YOUR_TOKEN"
body := bytes.NewBufferString(``)
req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/collections/chats/records?limit=20", body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out))
}
import java.net.URI;
import java.net.http.*;
var token = "YOUR_TOKEN";
var client = HttpClient.newHttpClient();
var req = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/collections/chats/records?limit=20"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.method("GET", HttpRequest.BodyPublishers.ofString("""
"""))
.build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
require "net/http"
require "json"
token = "YOUR_TOKEN"
uri = URI("https://api.skillsafe.ai/v1/app-api/collections/chats/records?limit=20")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts JSON.parse(res.body)["data"]
<?php
$token = "YOUR_TOKEN";
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/collections/chats/records?limit=20");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
]);
$out = curl_exec($ch);
print_r(json_decode($out, true)["data"]);
using System.Net.Http;
using System.Net.Http.Headers;
var token = "YOUR_TOKEN";
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var req = new HttpRequestMessage(new HttpMethod("GET"), "https://api.skillsafe.ai/v1/app-api/collections/chats/records?limit=20");
var res = await client.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
Rate limits
120 requests per minute on the data endpoints, 30 on similarity search. Back off on a
429; do not retry in a tight loop.