Drive the game from your own code
Twenty Questions is a session app: one game is one server-side conversation, and one question is one billed turn against the app's agent prompt. Everything below is the same API the page itself uses — there is no private endpoint.
Base URL https://api.skillsafe.ai/v1/app-api. Every response is the same envelope:
{"ok": true, "data": {…}} on success, {"ok": false, "error": {"code",
"message", "details"}} on failure. Pick your language once — the tabs switch together and
the choice is remembered.
Errors you will actually hit
| code | HTTP | what it means |
|---|---|---|
UNAUTHORIZED | 401 | No token, or a token that is not this app's. Mint one on /tokens.html. |
FORBIDDEN | 403 | A guest token tried to take a turn. Turns are metered, so they need a signed-in token. |
INSUFFICIENT_CREDITS | 402 | The balance cannot cover the hold for this turn. Estimate first; top up; the game carries on. |
NOT_FOUND | 404 | The session id is unknown, deleted or belongs to another subject. Create a new one and replay the state. |
VALIDATION_ERROR | 400 | The body was malformed - most often `content` missing, or sent as an object instead of a string. |
RATE_LIMITED | 429 | Too many calls. Back off and retry; never tight-loop. |
JOB_FAILED | - | The turn ran and failed upstream. The reply carries `error`; nothing advanced, so the move can be re-made. |
1. Get a token
Every call takes Authorization: Bearer aut_…. The easy way to get one is the token page: it reads the token this browser already holds for the app, shows whether it is a guest or a personal token, and gives you a one-click shell export. A guest token can call /me and /estimate. A game turn is metered, so it needs a personal token - sign in on that page to get one.
# Open https://twenty-questions.skillsafe.ai/tokens.html, sign in, then "Copy shell export".
export SKILLSAFE_TOKEN="aut_..." # then paste it wherever these samples say YOUR_TOKEN
# Open https://twenty-questions.skillsafe.ai/tokens.html, sign in, then "Copy token".
# Paste it wherever these samples say YOUR_TOKEN.
// Open https://twenty-questions.skillsafe.ai/tokens.html, sign in, then "Copy token".
// Paste it wherever these samples say YOUR_TOKEN.
// Open https://twenty-questions.skillsafe.ai/tokens.html, sign in, then "Copy token".
// Paste it wherever these samples say YOUR_TOKEN.
// Open https://twenty-questions.skillsafe.ai/tokens.html, sign in, then "Copy token".
// Paste it wherever these samples say YOUR_TOKEN.
# Open https://twenty-questions.skillsafe.ai/tokens.html, sign in, then "Copy token".
# Paste it wherever these samples say YOUR_TOKEN.
<?php
// Open https://twenty-questions.skillsafe.ai/tokens.html, sign in, then "Copy token".
// Paste it wherever these samples say YOUR_TOKEN.
// Open https://twenty-questions.skillsafe.ai/tokens.html, sign in, then "Copy token".
// Paste it wherever these samples say YOUR_TOKEN.
2. Check who you are and what you can spend
GET /me returns exactly three fields: subject_type, subject_id and credits. There is no email, no name and no id beyond the subject id, so the signed-in test is subject_type === "user" and nothing else. credits is the balance every turn is billed against.
curl -sS "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={"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"},
)
body = json.load(urllib.request.urlopen(req))
print(body["data"])
const res = await fetch("https://api.skillsafe.ai/v1/app-api/me", {
method: "GET",
headers: {
"Authorization": "Bearer YOUR_TOKEN",
"Content-Type": "application/json"
}
});
const { data, error } = await res.json();
if (error) throw new Error(error.code + ": " + error.message);
console.log(data);
package main
import (
"bytes"
"encoding/json"
"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)
var env map[string]json.RawMessage
json.Unmarshal(out, &env)
fmt.Println(string(env["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/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"
require "uri"
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_CUSTOMREQUEST => "GET",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
]);
$env = json_decode(curl_exec($ch), true);
var_dump($env["data"]);
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
var token = "YOUR_TOKEN";
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var req = new HttpRequestMessage(new HttpMethod("GET"), "https://api.skillsafe.ai/v1/app-api/me");
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
{"ok":true,"data":{"subject_type":"user","subject_id":"usr_...","credits":48210}}
3. Price a turn before you take one
POST /estimate is free, runs no job and charges nothing. Send it the same body you would send as a turn and it comes back with model, model_alias, markup_bps, hold_credits and min_credits. hold_credits is what gets reserved, not what you pay - the hold prices the full output cap and a twenty-questions turn is short, so the settled charge is usually far lower. A game is up to twenty turns, so multiply before you tell a player what a game costs.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"content": "[TWENTY QUESTIONS | TURN 1]\nMODE: guess\nINSTRUCTION: ask question 1."}'
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/estimate",
method="POST",
headers={"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"},
data=json.dumps({"content": "[TWENTY QUESTIONS | TURN 1]\nMODE: guess\nINSTRUCTION: ask question 1."}).encode(),
)
body = json.load(urllib.request.urlopen(req))
print(body["data"])
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": "[TWENTY QUESTIONS | TURN 1]\nMODE: guess\nINSTRUCTION: ask question 1."})
});
const { data, error } = await res.json();
if (error) throw new Error(error.code + ": " + error.message);
console.log(data);
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
func main() {
token := "YOUR_TOKEN"
body := bytes.NewBufferString(`{"content": "[TWENTY QUESTIONS | TURN 1]\nMODE: guess\nINSTRUCTION: ask question 1."}`)
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)
var env map[string]json.RawMessage
json.Unmarshal(out, &env)
fmt.Println(string(env["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/estimate"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{"content": "[TWENTY QUESTIONS | TURN 1]\nMODE: guess\nINSTRUCTION: ask question 1."}
"""))
.build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
require "net/http"
require "json"
require "uri"
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 = {"content": "[TWENTY QUESTIONS | TURN 1]\nMODE: guess\nINSTRUCTION: ask question 1."}.to_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_CUSTOMREQUEST => "POST",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => '{"content": "[TWENTY QUESTIONS | TURN 1]\nMODE: guess\nINSTRUCTION: ask question 1."}',
]);
$env = json_decode(curl_exec($ch), true);
var_dump($env["data"]);
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
var token = "YOUR_TOKEN";
using var http = new HttpClient();
http.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"": ""[TWENTY QUESTIONS | TURN 1]\nMODE: guess\nINSTRUCTION: ask question 1.""}", Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
{"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,"hold_credits":1130,"min_credits":24,"sponsor_enabled":false}}
4. Open a game
One game is one session. POST /sessions takes an empty body and returns a session_id; the app's agent prompt is attached server-side, and the conversation history is kept there too, so you never resend past messages. You may hold 20 live sessions per user and a session holds at most 200 messages, so delete a game when it ends.
curl -sS -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",
headers={"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"},
data=json.dumps({}).encode(),
)
body = json.load(urllib.request.urlopen(req))
print(body["data"])
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({})
});
const { data, error } = await res.json();
if (error) throw new Error(error.code + ": " + error.message);
console.log(data);
package main
import (
"bytes"
"encoding/json"
"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)
var env map[string]json.RawMessage
json.Unmarshal(out, &env)
fmt.Println(string(env["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"))
.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"
require "uri"
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 = {}.to_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_CUSTOMREQUEST => "POST",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => '{}',
]);
$env = json_decode(curl_exec($ch), true);
var_dump($env["data"]);
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
var token = "YOUR_TOKEN";
using var http = new HttpClient();
http.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(@"{}", Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
{"ok":true,"data":{"session":{"session_id":"ses_...","created_at":"2026-08-26T10:00:00Z"}}}
5. Take a turn
POST /sessions/{id}/messages with {"content": "…"}. content is a string - the turn envelope below - not an object. The call returns a job_id; poll GET /jobs/{id} until it reaches a terminal state, and read the reply from output.output. A turn is a job, so it holds credits, settles to charged_credits, and can come back truncated: true if the reply hit the output cap. There is no idempotency key on a session turn. Neither the API nor the SDK accepts one, and resending is worse than a double charge - it appends a second copy of the move to the server-side history. If you are unsure whether a turn landed, call GET /sessions/{id} and count the assistant messages instead of resending.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/sessions/ses_.../messages" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"content": "[TWENTY QUESTIONS | TURN 4]\nMODE: guess\nCATEGORY: object\nDIFFICULTY: normal\nQUESTIONS_USED: 3\nQUESTIONS_LEFT: 17\nESTABLISHED FACTS (authoritative - never contradict these):\n 01. Is it alive? -> no\n 02. Is it man-made? -> yes\n 03. Is it bigger than a microwave oven? -> no\nPLAYER REPLY TO QUESTION 3: no\nINSTRUCTION: ask question 4, or commit to a guess if you are confident."}'
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/sessions/ses_.../messages",
method="POST",
headers={"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"},
data=json.dumps({"content": "[TWENTY QUESTIONS | TURN 4]\nMODE: guess\nCATEGORY: object\nDIFFICULTY: normal\nQUESTIONS_USED: 3\nQUESTIONS_LEFT: 17\nESTABLISHED FACTS (authoritative - never contradict these):\n 01. Is it alive? -> no\n 02. Is it man-made? -> yes\n 03. Is it bigger than a microwave oven? -> no\nPLAYER REPLY TO QUESTION 3: no\nINSTRUCTION: ask question 4, or commit to a guess if you are confident."}).encode(),
)
body = json.load(urllib.request.urlopen(req))
print(body["data"])
const res = await fetch("https://api.skillsafe.ai/v1/app-api/sessions/ses_.../messages", {
method: "POST",
headers: {
"Authorization": "Bearer YOUR_TOKEN",
"Content-Type": "application/json"
},
body: JSON.stringify({"content": "[TWENTY QUESTIONS | TURN 4]\nMODE: guess\nCATEGORY: object\nDIFFICULTY: normal\nQUESTIONS_USED: 3\nQUESTIONS_LEFT: 17\nESTABLISHED FACTS (authoritative - never contradict these):\n 01. Is it alive? -> no\n 02. Is it man-made? -> yes\n 03. Is it bigger than a microwave oven? -> no\nPLAYER REPLY TO QUESTION 3: no\nINSTRUCTION: ask question 4, or commit to a guess if you are confident."})
});
const { data, error } = await res.json();
if (error) throw new Error(error.code + ": " + error.message);
console.log(data);
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
func main() {
token := "YOUR_TOKEN"
body := bytes.NewBufferString(`{"content": "[TWENTY QUESTIONS | TURN 4]\nMODE: guess\nCATEGORY: object\nDIFFICULTY: normal\nQUESTIONS_USED: 3\nQUESTIONS_LEFT: 17\nESTABLISHED FACTS (authoritative - never contradict these):\n 01. Is it alive? -> no\n 02. Is it man-made? -> yes\n 03. Is it bigger than a microwave oven? -> no\nPLAYER REPLY TO QUESTION 3: no\nINSTRUCTION: ask question 4, or commit to a guess if you are confident."}`)
req, _ := http.NewRequest("POST", "https://api.skillsafe.ai/v1/app-api/sessions/ses_.../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)
var env map[string]json.RawMessage
json.Unmarshal(out, &env)
fmt.Println(string(env["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/ses_.../messages"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString("""
{"content": "[TWENTY QUESTIONS | TURN 4]\nMODE: guess\nCATEGORY: object\nDIFFICULTY: normal\nQUESTIONS_USED: 3\nQUESTIONS_LEFT: 17\nESTABLISHED FACTS (authoritative - never contradict these):\n 01. Is it alive? -> no\n 02. Is it man-made? -> yes\n 03. Is it bigger than a microwave oven? -> no\nPLAYER REPLY TO QUESTION 3: no\nINSTRUCTION: ask question 4, or commit to a guess if you are confident."}
"""))
.build();
var res = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
require "net/http"
require "json"
require "uri"
token = "YOUR_TOKEN"
uri = URI("https://api.skillsafe.ai/v1/app-api/sessions/ses_.../messages")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"
req.body = {"content": "[TWENTY QUESTIONS | TURN 4]\nMODE: guess\nCATEGORY: object\nDIFFICULTY: normal\nQUESTIONS_USED: 3\nQUESTIONS_LEFT: 17\nESTABLISHED FACTS (authoritative - never contradict these):\n 01. Is it alive? -> no\n 02. Is it man-made? -> yes\n 03. Is it bigger than a microwave oven? -> no\nPLAYER REPLY TO QUESTION 3: no\nINSTRUCTION: ask question 4, or commit to a guess if you are confident."}.to_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/ses_.../messages");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => '{"content": "[TWENTY QUESTIONS | TURN 4]\nMODE: guess\nCATEGORY: object\nDIFFICULTY: normal\nQUESTIONS_USED: 3\nQUESTIONS_LEFT: 17\nESTABLISHED FACTS (authoritative - never contradict these):\n 01. Is it alive? -> no\n 02. Is it man-made? -> yes\n 03. Is it bigger than a microwave oven? -> no\nPLAYER REPLY TO QUESTION 3: no\nINSTRUCTION: ask question 4, or commit to a guess if you are confident."}',
]);
$env = json_decode(curl_exec($ch), true);
var_dump($env["data"]);
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
var token = "YOUR_TOKEN";
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var req = new HttpRequestMessage(new HttpMethod("POST"), "https://api.skillsafe.ai/v1/app-api/sessions/ses_.../messages");
req.Content = new StringContent(@"{""content"": ""[TWENTY QUESTIONS | TURN 4]\nMODE: guess\nCATEGORY: object\nDIFFICULTY: normal\nQUESTIONS_USED: 3\nQUESTIONS_LEFT: 17\nESTABLISHED FACTS (authoritative - never contradict these):\n 01. Is it alive? -> no\n 02. Is it man-made? -> yes\n 03. Is it bigger than a microwave oven? -> no\nPLAYER REPLY TO QUESTION 3: no\nINSTRUCTION: ask question 4, or commit to a guess if you are confident.""}", Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
{"ok":true,"data":{"job_id":"job_...","session_id":"ses_..."}}
# then GET /v1/app-api/jobs/job_...
{"ok":true,"data":{"status":"succeeded","charged_credits":214,"truncated":false,
"output":{"output":"THINKING: Small, man-made and not powered, so tools and utensils are the live field.\nRULED_OUT: appliances, vehicles, furniture\nCONFIDENCE: 12\nASK: Is it usually kept indoors?"}}}
6. Stream a turn instead
Add "stream": true and the same endpoint answers text/event-stream: a job event, then delta events carrying {"text"}, then one done event with the settled charged_credits. Because the reply is labelled lines rather than JSON, each line is renderable the moment it completes - which is why this app streams by default.
curl -N -X POST "https://api.skillsafe.ai/v1/app-api/sessions/ses_.../messages" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"stream": true, "content": "..."}'
# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"THINKING: Small, man-made"}
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":214}
# Same endpoint, body {"stream": true, "content": envelope}.
# Read the response line by line and parse the JSON after each "data:" prefix;
# frames are separated by a blank line. Events are: job, delta, done, error.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/sessions/ses_.../messages", {
method: "POST",
headers: { "Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json" },
body: JSON.stringify({ stream: true, content: envelope })
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
const line = frame.split("\n").find((l) => l.startsWith("data:"));
if (line) console.log(JSON.parse(line.slice(5)));
}
}
// Same endpoint, body {"stream": true, "content": envelope}.
// Read the response line by line and parse the JSON after each "data:" prefix;
// frames are separated by a blank line. Events are: job, delta, done, error.
// Same endpoint, body {"stream": true, "content": envelope}.
// Read the response line by line and parse the JSON after each "data:" prefix;
// frames are separated by a blank line. Events are: job, delta, done, error.
# Same endpoint, body {"stream": true, "content": envelope}.
# Read the response line by line and parse the JSON after each "data:" prefix;
# frames are separated by a blank line. Events are: job, delta, done, error.
<?php
// Same endpoint, body {"stream": true, "content": $envelope}.
// Read the response line by line and json_decode the text after each "data:" prefix;
// frames are separated by a blank line. Events are: job, delta, done, error.
// Same endpoint, body {"stream": true, "content": envelope}.
// Read the response line by line and parse the JSON after each "data:" prefix;
// frames are separated by a blank line. Events are: job, delta, done, error.
7. Settle a turn you are not sure landed
GET /sessions/{id} returns the session and its full message history. Count the assistant messages: if the server holds more than your own transcript accounts for, your turn did land and its reply is sitting there to be adopted. That is how this app recovers from a reload mid-turn without ever paying for the same question twice, and it is the substitute for the idempotency key sessions do not have.
curl -sS "https://api.skillsafe.ai/v1/app-api/sessions/ses_..." -H "Authorization: Bearer YOUR_TOKEN"
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/sessions/ses_...",
method="GET",
headers={"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"},
)
body = json.load(urllib.request.urlopen(req))
print(body["data"])
const res = await fetch("https://api.skillsafe.ai/v1/app-api/sessions/ses_...", {
method: "GET",
headers: {
"Authorization": "Bearer YOUR_TOKEN",
"Content-Type": "application/json"
}
});
const { data, error } = await res.json();
if (error) throw new Error(error.code + ": " + error.message);
console.log(data);
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
func main() {
token := "YOUR_TOKEN"
body := bytes.NewBufferString(``)
req, _ := http.NewRequest("GET", "https://api.skillsafe.ai/v1/app-api/sessions/ses_...", 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)
var env map[string]json.RawMessage
json.Unmarshal(out, &env)
fmt.Println(string(env["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/ses_..."))
.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"
require "uri"
token = "YOUR_TOKEN"
uri = URI("https://api.skillsafe.ai/v1/app-api/sessions/ses_...")
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/sessions/ses_...");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
]);
$env = json_decode(curl_exec($ch), true);
var_dump($env["data"]);
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
var token = "YOUR_TOKEN";
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var req = new HttpRequestMessage(new HttpMethod("GET"), "https://api.skillsafe.ai/v1/app-api/sessions/ses_...");
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
{"ok":true,"data":{"session_id":"ses_...","messages":[
{"role":"user","content":"[TWENTY QUESTIONS | TURN 1] ..."},
{"role":"assistant","content":"THINKING: ...\nCONFIDENCE: 0\nASK: Is it alive?"}
]}}
8. Close the game
DELETE /sessions/{id} removes the session and its messages. Do it when a game ends: twenty live sessions is the cap, and an app that leaks them stops being able to start games.
curl -sS "https://api.skillsafe.ai/v1/app-api/sessions/ses_..." -H "Authorization: Bearer YOUR_TOKEN"
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/sessions/ses_...",
method="DELETE",
headers={"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"},
)
body = json.load(urllib.request.urlopen(req))
print(body["data"])
const res = await fetch("https://api.skillsafe.ai/v1/app-api/sessions/ses_...", {
method: "DELETE",
headers: {
"Authorization": "Bearer YOUR_TOKEN",
"Content-Type": "application/json"
}
});
const { data, error } = await res.json();
if (error) throw new Error(error.code + ": " + error.message);
console.log(data);
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
func main() {
token := "YOUR_TOKEN"
body := bytes.NewBufferString(``)
req, _ := http.NewRequest("DELETE", "https://api.skillsafe.ai/v1/app-api/sessions/ses_...", 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)
var env map[string]json.RawMessage
json.Unmarshal(out, &env)
fmt.Println(string(env["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/ses_..."))
.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"
require "uri"
token = "YOUR_TOKEN"
uri = URI("https://api.skillsafe.ai/v1/app-api/sessions/ses_...")
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/ses_...");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => "DELETE",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", "Content-Type: application/json"],
]);
$env = json_decode(curl_exec($ch), true);
var_dump($env["data"]);
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
var token = "YOUR_TOKEN";
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var req = new HttpRequestMessage(new HttpMethod("DELETE"), "https://api.skillsafe.ai/v1/app-api/sessions/ses_...");
var res = await http.SendAsync(req);
Console.WriteLine(await res.Content.ReadAsStringAsync());
{"ok":true,"data":{"deleted":true}}
The turn envelope
content is a single string: a labelled block that restates the entire state of the
game. It is regenerated from scratch on every turn on purpose. The platform truncates a long
conversation oldest-pair-first, and in twenty questions the earliest answers are the ones
everything later depends on — so nothing here relies on the conversation surviving. A useful
side effect: because the envelope is self-sufficient, a game whose session has gone can be
resumed by opening a new one and sending the same envelope.
A turn in guess mode, where the player holds the secret:
[TWENTY QUESTIONS | TURN 4]
MODE: guess
CATEGORY: object
DIFFICULTY: normal
QUESTIONS_USED: 3
QUESTIONS_LEFT: 17
ESTABLISHED FACTS (authoritative - never contradict these):
01. Is it alive? -> no
02. Is it man-made? -> yes
03. Is it bigger than a microwave oven? -> no
PLAYER REPLY TO QUESTION 3: no
INSTRUCTION: ask question 4, or commit to a guess if you are confident.
A turn in hold mode, where the app holds it. Note the SECRET line
— it is re-sent every single turn, so the model is reporting a property of a fixed string
rather than inventing one that fits the questions asked so far:
[TWENTY QUESTIONS | TURN 4]
MODE: hold
CATEGORY: concept
DIFFICULTY: normal
QUESTIONS_USED: 3
QUESTIONS_LEFT: 17
SECRET: a rainbow
ANSWERS YOU HAVE ALREADY GIVEN (authoritative - never contradict these):
01. Is it alive? -> no
02. Is it man-made? -> no
03. Is it something you can see? -> yes
PLAYER QUESTION: Can you touch it?
INSTRUCTION: answer this question about SECRET, truthfully and consistently with the answers above. Do not reveal SECRET.
The reply contract
Replies are plain labelled lines, not JSON: LABEL: value, one per line, a wrapped
line continuing the label above it. Three reasons. They stream, so a half-arrived reply still
renders. One stray comma cannot destroy a turn. And a formatting slip degrades into an odd-looking
card rather than an empty one.
| label | value | turns | meaning |
|---|---|---|---|
THINKING | one line | any | One sentence on what the facts now narrow to. Shown to the player. |
RULED_OUT | comma list or none | ask | What the last answer eliminated. |
CONFIDENCE | 0–100 | ask | How close the model is to naming it. |
ASK | a question | ask | The next question. Yes/no answerable, ends in a question mark. |
GUESS | a noun phrase | ask | A commitment. A wrong one costs a question. |
ANSWER | yes · no · sometimes · irrelevant | answer | The answer about the held secret. |
NOTE | one line | answer, verdict | A clarification of the question. Never a spoiler. |
SECRET | a noun phrase | setup | The chosen secret. Setup turn only. |
CATEGORY | one word | setup | object, animal, person, place, food or concept. |
HINT | one line | setup | Vague, shown before the first question. |
VERDICT | correct · incorrect | verdict | Judging the player's final guess. |
REVEAL | the secret | verdict, finish | Only when the game ends. |
RESULT | win · loss | finish | From the model's side. |
SAY | one or two sentences | setup, verdict, finish | Closing the game. |
Each turn shape declares what it must return. A reply missing a required label is a protocol
failure: send the move again with a FORMAT CORRECTION: line naming the missing labels,
and count it as the extra billed turn it is.
| shape | mode | required | optional |
|---|---|---|---|
setup | hold | SECRET | CATEGORY, HINT, SAY |
ask | guess | ASK or GUESS | THINKING, RULED_OUT, CONFIDENCE |
answer | hold | ANSWER | NOTE, THINKING |
verdict | hold | VERDICT | REVEAL, SAY, NOTE |
finish | both | RESULT | SAY, REVEAL, GUESS |
Rules a client has to keep for itself
- Count the questions yourself. Twenty is the cap and the model does not enforce
it. Increment on the player's move, not on the model's reply, so a failed turn cannot silently
cost a question. On the twentieth, change the instruction to demand a
GUESS. - A guess spends a question, right or wrong, and a wrong one goes into
ALREADY GUESSED AND WRONG, which is what stops it coming back. - Never resend a turn blindly. There is no idempotency key. Count the assistant messages on the session instead.
- Delete the session when the game ends. Twenty live sessions per user.
- If you hold the secret, hash it before question one and check the hash against the reveal. Without that there is nothing stopping the answer becoming whatever fits.