Driving Text Adventure from your own code
Text Adventure is a session app. A story is a conversation: you create a session
once, then send one message per turn against the app's system prompt, and the platform keeps the
conversation server-side. Everything below runs against the app API at
https://api.skillsafe.ai/v1/app-api.
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 a turn. |
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 play a turn. /me and /estimate work for guests; a turn 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 world. |
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 a turn. |
POST /sessions/{id}/messages times out, the move may still have landed — and
resending it appends a second copy of the same move to the server-side history, which is
worse than a double charge, because the model then narrates a story in which the player did the
thing twice. 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 turn landed: adopt the reply it is
already holding. That is what the app itself does.
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://text-adventure.skillsafe.ai/tokens.html — the page reads the
# token this browser already holds, so you never open the developer console.
export QUEST_TOKEN="YOUR_TOKEN"
# A guest token is enough for /me and /estimate. Playing a turn 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":"text-adventure"}'
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://text-adventure.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://text-adventure.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"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const token = "YOUR_TOKEN" // from https://text-adventure.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) {
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 Quest {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN"; // text-adventure.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://text-adventure.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://text-adventure.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 Quest {
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Token = "YOUR_TOKEN"; // text-adventure.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 $QUEST_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 a turn costs — POST /estimate
Free, and it runs no job. Returns hold_credits (what is reserved before the turn
runs, priced at the full output cap), min_credits, model,
model_alias and markup_bps. What you are actually charged comes back on
the turn itself and is usually well under the hold.
Estimate against a real envelope, not a short probe string — a turn late in a long story carries far more text than a turn on turn one, and an estimate taken against a stub understates every hold.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer $QUEST_TOKEN" \
-H "Content-Type: application/json" \
-d '{"turn": "[TEXT QUEST | TURN 6]\n...the full envelope from step 5..."}'
r = call("POST", "/estimate", body={
"turn": "[TEXT QUEST | TURN 6]\n...the full envelope from step 5..."
})
hold = r["hold_credits"]
print(hold)
const r = await call("POST", "/estimate", {
"turn": "[TEXT QUEST | TURN 6]\n...the full envelope from step 5..."
});
const hold = r["hold_credits"];
console.log(hold);
r, err := call("POST", "/estimate", []byte(`{"turn": "[TEXT QUEST | TURN 6]\n...the full envelope from step 5..."}`))
if err != nil { log.Fatal(err) }
fmt.Println(r)
var r = call("POST", "/estimate", """
{"turn": "[TEXT QUEST | TURN 6]\n...the full envelope from step 5..."}
""");
System.out.println(r);
r = call("POST", "/estimate", {"turn": "[TEXT QUEST | TURN 6]\n...the full envelope from step 5..."})
puts r
$r = call("POST", "/estimate", json_decode('{"turn": "[TEXT QUEST | TURN 6]\n...the full envelope from step 5..."}', true));
print_r($r);
var r = await Call("POST", "/estimate", @"{""turn"": ""[TEXT QUEST | TURN 6]\n...the full envelope from step 5...""}");
Console.WriteLine(r);
4. Open a session — POST /sessions
One session per story. 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 story ends.
Because the envelope carries the whole world, the session is disposable: the app rotates it deliberately every forty turns to stay clear of the message cap, and if one 404s mid-story it simply opens another and sends the same envelope.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/sessions" \
-H "Authorization: Bearer $QUEST_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 turn 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 turn kind
is stated inside the envelope as an INSTRUCTION line rather than passed
alongside it.
Restate everything the story depends on, every time:
[TEXT QUEST | TURN 6]
SETTING: The Long Cellar
PREMISE: A house that is bigger underneath than it is on top, and an aunt who left you the keys.
PERIL: standard
SCENE LENGTH: brisk
SETTING RULES: The dread here is architectural and quiet. Things are wrong by being the wrong
size or in the wrong place, never by being gory.
TURN: 6
LOCATION: The gauge cupboard - whitewashed, dry, and far too tidy for down here
CARRYING:
- your aunt's torch (heavy, brass, warm to hold)
- a ring of eleven keys (none of them small)
- a tin cup
ESTABLISHED (authoritative - never contradict, never un-set):
- the iron door is open [T2]
- the water is rising without a source [T1]
FORECLOSED (these are gone for good; do not re-open them):
- the stair back to the fourth landing has collapsed [T5]
NOW POSSIBLE:
- the shaft below the cupboard [T5]
OPEN THREADS:
- Find out why the cellar goes down further than the house is tall.
PLACES KNOWN: The fourth landing, The gauge cupboard
THE STORY SO FAR (one line per turn; a * marks a turn that changed something):
T0* The story opens: your aunt's house has one storey, the stairs have four landings.
T1* Found an inch of rainwater in the tin cup after a fortnight of no rain.
T2* The tenth key opened the iron door.
T5* The stair collapsed behind you; the shaft opened below.
THE LAST FEW SCENES, AS WRITTEN:
[T5 | you: go down the stairs] The brick stops being brick. What carries on down is
cut rock, and it curves...
PLAYER ACTION: read the slate on the wall
INSTRUCTION: play out that action. Narrate what happens, then declare every consequence as
labels. If the action is impossible here, say why in the narration and change nothing.
Keep it bounded. The app budgets the whole envelope to 5,200 characters and, when a long story would exceed that, walks a fixed ladder of degradations: fewer recent scenes, then shorter ones, then fewer record lines, then item and place notes. Location, inventory names, established facts, foreclosures, open threads and the player's own action are on no rung of that ladder. Give up recall, never authority.
Compress the past by keeping the turns that changed something and thinning the turns that only described something. You already know which is which — it is whichever turns came back with mutation labels — so it needs no second model call, and it is the right cut, because the turns a later scene can contradict are exactly the turns that changed the world.
6. Play a turn — 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
— render what arrived and offer a top-up, rather than presenting a clipped scene as
complete.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/sessions/{session_id}/messages" \
-H "Authorization: Bearer $QUEST_TOKEN" \
-H "Content-Type: application/json" \
-d '{"content": "[TEXT QUEST | TURN 6]\n...the full envelope from step 5..."}'
r = call("POST", "/sessions/{session_id}/messages", body={
"content": "[TEXT QUEST | TURN 6]\n...the full envelope from step 5..."
})
reply = r["text"]
print(reply)
const r = await call("POST", "/sessions/{session_id}/messages", {
"content": "[TEXT QUEST | TURN 6]\n...the full envelope from step 5..."
});
const reply = r["text"];
console.log(reply);
r, err := call("POST", "/sessions/{session_id}/messages", []byte(`{"content": "[TEXT QUEST | TURN 6]\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": "[TEXT QUEST | TURN 6]\n...the full envelope from step 5..."}
""");
System.out.println(r);
r = call("POST", "/sessions/{session_id}/messages", {"content": "[TEXT QUEST | TURN 6]\n...the full envelope from step 5..."})
puts r
$r = call("POST", "/sessions/{session_id}/messages", json_decode('{"content": "[TEXT QUEST | TURN 6]\n...the full envelope from step 5..."}', true));
print_r($r);
var r = await Call("POST", "/sessions/{session_id}/messages", @"{""content"": ""[TEXT QUEST | TURN 6]\n...the full envelope from step 5...""}");
Console.WriteLine(r);
7. The same turn, 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. A JSON contract would give you an unparseable prefix for the whole of the wait and nothing at all if the connection dropped.
# Add "stream": true and read the SSE frames as they arrive. Each `delta`
# frame carries a fragment of the reply; `done` carries the finished turn.
curl -N -X POST "https://api.skillsafe.ai/v1/app-api/sessions/$SESSION/messages" \
-H "Authorization: Bearer $QUEST_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{"stream":true,"content":"[TEXT QUEST | TURN 2]\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 a half-arrived reply is
# already renderable — this is why the contract is not JSON.
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. NARRATION is the only label whose value may contain blank lines.
NARRATION: The slate is ruled into a column of dates and a column of readings, in your
aunt's small upright hand. The readings are not in inches. They are in landings.
FIRST, it says, and a date eleven years old. SECOND, four years after that. THIRD, and then
FOURTH - and the date beside FOURTH is a fortnight ago, which is the last time it did not rain.
MOVE: The gauge cupboard
PLACE_NOTE: whitewashed, dry, and far too tidy for this far down
GET: a stick of chalk
FLAG: the water has risen four landings in eleven years
THREAD: Find out what your aunt was measuring, and what happens at the fifth.
CHRONICLE: Read the slate: the water is logged in landings, not inches, and FOURTH was a fortnight ago.
EXITS: look for a fifth landing, take the slate, go back to the shaft, put the cup under the drip
MOOD: cold
| Label | Meaning |
|---|---|
NARRATION | The scene. Prose, second person, present tense. The only label whose value may span several lines and contain blank lines - those blank lines are the paragraph breaks. |
MOVE | The name of the place the player is now, if it changed. Slugified into a place id and checked against the foreclosed list before it is applied. |
PLACE_NOTE | One short line describing that place, kept on the map. |
GET | Comma-separated things now carried. Always allowed unless it is literally a duplicate. |
DROP | Comma-separated things no longer carried. Refused if the item was not being carried. |
FLAG | Comma-separated facts now true of the world. Additive; a flag is never un-set. |
CLOSED | Comma-separated things foreclosed for good. A later MOVE that matches one of these is refused. |
OPENED | Comma-separated things now possible that were not. |
THREAD | One new goal. |
RESOLVE | An open thread, now finished. Refused if no open thread matches. |
CHRONICLE | One line under 120 characters recording what this turn did. Sent every turn. |
EXITS | 3-6 things the player could try next, phrased as a player would type them. |
MOOD | A single word for the tone of the scene. |
ENDING | victory, death, stalemate or departure. Only on an ending turn. |
ENDING_TEXT | One or two sentences closing it out. Only with ENDING. |
NOTE | One short out-of-scene clarification. Rare. |
Treat the mutation labels as proposals, not facts. Validate each against the world you
hold before applying it: refuse a DROP of something not carried, refuse a
MOVE onto something already CLOSED, refuse a RESOLVE of a
thread that is not open. Apply drops before gains, so that a turn which trades one thing for
another resolves in the order the narration describes. Surface the refusals to the reader rather
than absorbing them — a narrator that has started spending things you do not have is telling
you something.
Turn kinds and their required labels
| Kind | When | Required labels |
|---|---|---|
open | The first scene of a custom premise. | NARRATION, MOVE |
act | The ordinary turn. Almost every turn. | NARRATION |
nudge | Looking around. Must change nothing. | NARRATION, EXITS |
close | Ending the story. | NARRATION, ENDING |
9. Close the session — DELETE /sessions/{id}
Do this when a story ends. Sessions cap at twenty live per user, and leaking them eventually means you cannot start a new story at all.
curl -s -X DELETE "https://api.skillsafe.ai/v1/app-api/sessions/{session_id}" \
-H "Authorization: Bearer $QUEST_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 run as one row in a declared expeditions collection
(acl_read: owner, acl_write: user), created on the first turn and
updated on every later one. Two 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. - Records nest under
doc:{record_id, doc: {...}}. Read fields flat off the record and every one of them isundefined. query()resolves to{records, next_cursor}, butsimilar()resolves to the records array itself. They are not symmetrical.