Driving this app from your own code
Base URL https://api.skillsafe.ai/v1/app-api. Every response is the envelope
{"data": {...}} on success or {"error": {"code": "...", "message": "..."}}
on failure, and every call except the guest handshake takes
Authorization: Bearer <token>. Get a token on the
tokens page without touching developer tools.
The input contract
The turn body is {"content": "<the whole envelope>"} and nothing else. There is no task field and no lane router — this app has one contract. The envelope is a block of labelled plain-text lines that the page builds fresh on every turn: the fiction and content absolutes, the adult confirmation, the care obligation, the character recompiled from its seed, tonight's state, how much attention is left, the house style and the person's message. It is restated in full every turn because server-side history is trimmed from the oldest end, which is exactly where the rules would be.
The declared input schema is a single field: content, a string.
Errors
| Code | Means |
|---|---|
401 | No token, or an expired one. On a browser that has never used the app this is the correct first answer; mint a guest token and retry. |
402 | Balance below min_credits. Price the turn first and never let a user reach submit without the credits for it. |
400 slug is required | You put the slug in a header. It goes in the body of /guest. |
429 | Rate limited. Back off; never tight-loop. |
1. A token
Guests can browse and price a turn; a turn that is actually answered needs a signed-in user. The slug goes in the BODY. An X-App-Slug header returns 400 slug is required on this endpoint whatever other documentation says.
curl -s -X POST 'https://api.skillsafe.ai/v1/app-api/guest' \
-H 'Content-Type: application/json' \
-d '{"slug": "virtual-girlfriend"}'
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest",
method="POST",
data=json.dumps({"slug": "virtual-girlfriend"}).encode(),
headers={"User-Agent": "vg-example/1.0", "Content-Type": "application/json"},
)
print(json.load(urllib.request.urlopen(req)))
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({"slug": "virtual-girlfriend"}),
});
console.log(await res.json());
body := strings.NewReader(`{"slug": "virtual-girlfriend"}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/guest", body)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{"slug": "virtual-girlfriend"}"""))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = {"slug": "virtual-girlfriend"}.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body
$ch = curl_init('https://api.skillsafe.ai/v1/app-api/guest');
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode({"slug": "virtual-girlfriend"}));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
echo curl_exec($ch);
var req = new HttpRequestMessage(new HttpMethod("POST"), "https://api.skillsafe.ai/v1/app-api/guest");
req.Content = new StringContent(@"{""slug"": ""virtual-girlfriend""}", Encoding.UTF8, "application/json");
var resp = await client.SendAsync(req);
Console.WriteLine(await resp.Content.ReadAsStringAsync());
2. Who you are
Returns exactly three fields: subject_type, subject_id and credits. No name, no email. Signed in is subject_type == "user".
curl -s -X GET 'https://api.skillsafe.ai/v1/app-api/me' \
-H 'Authorization: Bearer YOUR_TOKEN'
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/me",
method="GET",
headers={"User-Agent": "vg-example/1.0", "Authorization": "Bearer " + YOUR_TOKEN},
)
print(json.load(urllib.request.urlopen(req)))
const res = await fetch("https://api.skillsafe.ai/v1/app-api/me", {
method: "GET",
headers: {
"Authorization": `Bearer ${YOUR_TOKEN}`,
},
});
console.log(await res.json());
var body io.Reader = nil
req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/me", body)
req.Header.Set("Authorization", "Bearer "+yourToken)
resp, err := http.DefaultClient.Do(req)
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/me"))
.header("Authorization", "Bearer " + yourToken)
.method("GET", HttpRequest.BodyPublishers.noBody())
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
uri = URI("https://api.skillsafe.ai/v1/app-api/me")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{your_token}"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body
$ch = curl_init('https://api.skillsafe.ai/v1/app-api/me');
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $yourToken]);
echo curl_exec($ch);
var req = new HttpRequestMessage(new HttpMethod("GET"), "https://api.skillsafe.ai/v1/app-api/me");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", yourToken);
var resp = await client.SendAsync(req);
Console.WriteLine(await resp.Content.ReadAsStringAsync());
3. Price a turn
Free, no job, no charge. Returns hold_credits (what is reserved, priced against the full output cap), min_credits, model and markup_bps. This endpoint validates nothing. A bare string comes back with a clean estimate and a correct model binding, so a clean estimate proves the model wiring and says nothing whatever about whether your input shape is right. Guard that in your own client.
curl -s -X POST 'https://api.skillsafe.ai/v1/app-api/estimate' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"content": "MODE: one evening...\n\nTHEIR MESSAGE\nevening. long day?"}'
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/estimate",
method="POST",
data=json.dumps({"content": "MODE: one evening...\n\nTHEIR MESSAGE\nevening. long day?"}).encode(),
headers={"User-Agent": "vg-example/1.0", "Authorization": "Bearer " + YOUR_TOKEN, "Content-Type": "application/json"},
)
print(json.load(urllib.request.urlopen(req)))
const res = await fetch("https://api.skillsafe.ai/v1/app-api/estimate", {
method: "POST",
headers: {
"Authorization": `Bearer ${YOUR_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"content": "MODE: one evening...\n\nTHEIR MESSAGE\nevening. long day?"}),
});
console.log(await res.json());
body := strings.NewReader(`{"content": "MODE: one evening...\n\nTHEIR MESSAGE\nevening. long day?"}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/estimate", body)
req.Header.Set("Authorization", "Bearer "+yourToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/estimate"))
.header("Authorization", "Bearer " + yourToken)
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{"content": "MODE: one evening...\n\nTHEIR MESSAGE\nevening. long day?"}"""))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
uri = URI("https://api.skillsafe.ai/v1/app-api/estimate")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{your_token}"
req["Content-Type"] = "application/json"
req.body = {"content": "MODE: one evening...\n\nTHEIR MESSAGE\nevening. long day?"}.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body
$ch = curl_init('https://api.skillsafe.ai/v1/app-api/estimate');
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode({"content": "MODE: one evening...\n\nTHEIR MESSAGE\nevening. long day?"}));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $yourToken, 'Content-Type: application/json']);
echo curl_exec($ch);
var req = new HttpRequestMessage(new HttpMethod("POST"), "https://api.skillsafe.ai/v1/app-api/estimate");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", yourToken);
req.Content = new StringContent(@"{""content"": ""MODE: one evening...\n\nTHEIR MESSAGE\nevening. long day?""}", Encoding.UTF8, "application/json");
var resp = await client.SendAsync(req);
Console.WriteLine(await resp.Content.ReadAsStringAsync());
4. Open a session
A session holds the conversation server-side against this app's system prompt. Sessions cap at 20 live and 200 messages; delete them when you are done or you will eventually be unable to open one.
curl -s -X POST 'https://api.skillsafe.ai/v1/app-api/sessions' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{}'
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/sessions",
method="POST",
data=json.dumps({}).encode(),
headers={"User-Agent": "vg-example/1.0", "Authorization": "Bearer " + YOUR_TOKEN, "Content-Type": "application/json"},
)
print(json.load(urllib.request.urlopen(req)))
const res = await fetch("https://api.skillsafe.ai/v1/app-api/sessions", {
method: "POST",
headers: {
"Authorization": `Bearer ${YOUR_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
console.log(await res.json());
body := strings.NewReader(`{}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/sessions", body)
req.Header.Set("Authorization", "Bearer "+yourToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/sessions"))
.header("Authorization", "Bearer " + yourToken)
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{}"""))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
uri = URI("https://api.skillsafe.ai/v1/app-api/sessions")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{your_token}"
req["Content-Type"] = "application/json"
req.body = {}.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body
$ch = curl_init('https://api.skillsafe.ai/v1/app-api/sessions');
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode({}));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $yourToken, 'Content-Type: application/json']);
echo curl_exec($ch);
var req = new HttpRequestMessage(new HttpMethod("POST"), "https://api.skillsafe.ai/v1/app-api/sessions");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", yourToken);
req.Content = new StringContent(@"{}", Encoding.UTF8, "application/json");
var resp = await client.SendAsync(req);
Console.WriteLine(await resp.Content.ReadAsStringAsync());
5. Send a turn
Returns {job_id, session_id}. Poll the job to a terminal state; the reply text is at output.output — one level deeper than it looks. There is no idempotency key on this endpoint, on either the polling or the streaming path. Do not blindly resend a turn that appeared to fail: a resent turn appends a second copy of the message to server-side history, which is worse than a double charge. Reconcile instead — read the session back, count the assistant messages, and adopt the reply if the server holds more than you have accounted for.
curl -s -X POST 'https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages' \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{"content": "...the envelope..."}'
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages",
method="POST",
data=json.dumps({"content": "...the envelope..."}).encode(),
headers={"User-Agent": "vg-example/1.0", "Authorization": "Bearer " + YOUR_TOKEN, "Content-Type": "application/json"},
)
print(json.load(urllib.request.urlopen(req)))
const res = await fetch("https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${YOUR_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"content": "...the envelope..."}),
});
console.log(await res.json());
body := strings.NewReader(`{"content": "...the envelope..."}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages", body)
req.Header.Set("Authorization", "Bearer "+yourToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages"))
.header("Authorization", "Bearer " + yourToken)
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{"content": "...the envelope..."}"""))
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
uri = URI("https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{your_token}"
req["Content-Type"] = "application/json"
req.body = {"content": "...the envelope..."}.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body
$ch = curl_init('https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages');
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode({"content": "...the envelope..."}));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $yourToken, 'Content-Type: application/json']);
echo curl_exec($ch);
var req = new HttpRequestMessage(new HttpMethod("POST"), "https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID/messages");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", yourToken);
req.Content = new StringContent(@"{""content"": ""...the envelope...""}", Encoding.UTF8, "application/json");
var resp = await client.SendAsync(req);
Console.WriteLine(await resp.Content.ReadAsStringAsync());
6. Poll the job
Terminal statuses are succeeded, failed and cancelled. charged_credits is the real cost and is usually well below the hold. truncated: true means the reply hit the output cap, not that it errored.
curl -s -X GET 'https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID' \
-H 'Authorization: Bearer YOUR_TOKEN'
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID",
method="GET",
headers={"User-Agent": "vg-example/1.0", "Authorization": "Bearer " + YOUR_TOKEN},
)
print(json.load(urllib.request.urlopen(req)))
const res = await fetch("https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID", {
method: "GET",
headers: {
"Authorization": `Bearer ${YOUR_TOKEN}`,
},
});
console.log(await res.json());
var body io.Reader = nil
req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID", body)
req.Header.Set("Authorization", "Bearer "+yourToken)
resp, err := http.DefaultClient.Do(req)
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID"))
.header("Authorization", "Bearer " + yourToken)
.method("GET", HttpRequest.BodyPublishers.noBody())
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
uri = URI("https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{your_token}"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body
$ch = curl_init('https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID');
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $yourToken]);
echo curl_exec($ch);
var req = new HttpRequestMessage(new HttpMethod("GET"), "https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", yourToken);
var resp = await client.SendAsync(req);
Console.WriteLine(await resp.Content.ReadAsStringAsync());
7. Delete the session
Leak sessions and the app eventually cannot start one.
curl -s -X DELETE 'https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID' \
-H 'Authorization: Bearer YOUR_TOKEN'
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID",
method="DELETE",
headers={"User-Agent": "vg-example/1.0", "Authorization": "Bearer " + YOUR_TOKEN},
)
print(json.load(urllib.request.urlopen(req)))
const res = await fetch("https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID", {
method: "DELETE",
headers: {
"Authorization": `Bearer ${YOUR_TOKEN}`,
},
});
console.log(await res.json());
var body io.Reader = nil
req, _ := http.NewRequest("DELETE", "https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID", body)
req.Header.Set("Authorization", "Bearer "+yourToken)
resp, err := http.DefaultClient.Do(req)
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID"))
.header("Authorization", "Bearer " + yourToken)
.method("DELETE", HttpRequest.BodyPublishers.noBody())
.build();
var resp = client.send(req, HttpResponse.BodyHandlers.ofString());
uri = URI("https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID")
req = Net::HTTP::Delete.new(uri)
req["Authorization"] = "Bearer #{your_token}"
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
puts res.body
$ch = curl_init('https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID');
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $yourToken]);
echo curl_exec($ch);
var req = new HttpRequestMessage(new HttpMethod("DELETE"), "https://api.skillsafe.ai/v1/app-api/sessions/SESSION_ID");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", yourToken);
var resp = await client.SendAsync(req);
Console.WriteLine(await resp.Content.ReadAsStringAsync());
8. Streaming a turn
Add "stream": true to the turn body and read text/event-stream.
The wire format is a named event plus a data line, terminated by a blank line —
not a type field inside the JSON, which is what several published samples claim and
which produces a parser that never fires. Event names are job, delta,
done, pending and error.
event: job
data: {"job_id":"job_...","session_id":"ses_..."}
event: delta
data: {"text":"the first few words"}
event: delta
data: {"text":" and the next few"}
event: done
data: {"status":"succeeded","charged_credits":812,"truncated":false}
One more trap, from the browser side: the vendored SDK's onDelta
callback is handed the string, not the frame object — it calls
onDelta(data.text || ""). The natural
onDelta: d => buf += d.text therefore accumulates undefined for ever
while every offline test passes. onJob does receive the object, which is what
makes the wrong guess feel confirmed.
9. What comes back
Plain text: her reply, and nothing else. No headings, no JSON, no labels. Whatever you do with it, the two things this app checks on its own output are worth reproducing — a reply that claims to be a person or reachable, and a reply that promises a next time. The last one is specific to this design: nothing survives an evening, so a sentence about later is not a promise, it is false.