← Text Adventure / API
Your token

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 one thing that makes this app different from most. The conversation is not where the story lives. Session history is truncated oldest-pair-first as it grows and caps at 200 messages, so a long adventure would quietly lose its own opening. Instead the caller owns the world state and restates all of it in every turn. That is what the envelope in step 5 is, and it is why a lost session costs you nothing.

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

StatusCodeWhat it means
400VALIDATION_ERRORThe body was malformed - most often a missing content on a turn.
401UNAUTHORIZEDNo token, or a token that has expired. Mint a new one.
402INSUFFICIENT_CREDITSThe balance is below min_credits. Nothing was charged and nothing was appended to the session.
403FORBIDDENA guest token tried to play a turn. /me and /estimate work for guests; a turn does not.
404NOT_FOUNDThe session is gone - deleted, or expired. Create a fresh one and send the same envelope; it carries the whole world.
409CONFLICTToo many live sessions. Delete some; the cap is twenty per user.
429RATE_LIMITEDBack off and retry. Never tight-loop.
500INTERNALTransient. Retry once - but see the warning about resending a turn.
Do not blindly retry a turn. A session turn accepts no idempotency key. If a 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
LabelMeaning
NARRATIONThe 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.
MOVEThe 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_NOTEOne short line describing that place, kept on the map.
GETComma-separated things now carried. Always allowed unless it is literally a duplicate.
DROPComma-separated things no longer carried. Refused if the item was not being carried.
FLAGComma-separated facts now true of the world. Additive; a flag is never un-set.
CLOSEDComma-separated things foreclosed for good. A later MOVE that matches one of these is refused.
OPENEDComma-separated things now possible that were not.
THREADOne new goal.
RESOLVEAn open thread, now finished. Refused if no open thread matches.
CHRONICLEOne line under 120 characters recording what this turn did. Sent every turn.
EXITS3-6 things the player could try next, phrased as a player would type them.
MOODA single word for the tone of the scene.
ENDINGvictory, death, stalemate or departure. Only on an ending turn.
ENDING_TEXTOne or two sentences closing it out. Only with ENDING.
NOTEOne 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

KindWhenRequired labels
openThe first scene of a custom premise.NARRATION, MOVE
actThe ordinary turn. Almost every turn.NARRATION
nudgeLooking around. Must change nothing.NARRATION, EXITS
closeEnding 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: