← Murder Mystery Game / API
Your token

Driving Murder Mystery Game from your own code

Murder Mystery Game is a session app. An interrogation is a conversation: you create a session once, then send one message per question 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 model does not hold the case, and it is not allowed to. The caller generates the case — culprit, alibis, evidence chain — proves it solvable, and then restates the whole of it in every envelope. Each message carries the public case file plus a private briefing for the one suspect being voiced, which is why the model can never leak a fact about anybody else: it does not have one. Session history truncates oldest-pair-first and caps at 200 messages, and none of that matters here, because nothing load-bearing was ever in the conversation.
A reply is a set of proposals, not a set of facts. The two labels that can rewrite a case are WHERE (the room this suspect names for the murder window) and REVEAL (a concession). Check WHERE against what your own case file has this person claiming, and check every REVEAL id against the exact concession list you put in the envelope. A mismatch is not colour — it is the model writing the case. Discard the reply and ask again.

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 ask a question. /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 case.
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 suspect then answers a question the detective only asked once. 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://murder-mystery-game.skillsafe.ai/tokens.html — the page reads the
# token this browser already holds, so you never open the developer console.
export MYSTERY_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":"murder-mystery-game"}'
import json, urllib.request

BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"   # from https://murder-mystery-game.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://murder-mystery-game.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://murder-mystery-game.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"; // murder-mystery-game.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://murder-mystery-game.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://murder-mystery-game.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";  // murder-mystery-game.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 $MYSTERY_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 $MYSTERY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"turn": "[MYSTERY BOX | QUESTION 4]\n...the full envelope from step 5..."}'
r = call("POST", "/estimate", body={
      "turn": "[MYSTERY BOX | QUESTION 4]\n...the full envelope from step 5..."
    })
hold = r["hold_credits"]
print(hold)
const r = await call("POST", "/estimate", {
    "turn": "[MYSTERY BOX | QUESTION 4]\n...the full envelope from step 5..."
  });
const hold = r["hold_credits"];
console.log(hold);
r, err := call("POST", "/estimate", []byte(`{"turn": "[MYSTERY BOX | QUESTION 4]\n...the full envelope from step 5..."}`))
if err != nil { log.Fatal(err) }
fmt.Println(r)
var r = call("POST", "/estimate", """
{"turn": "[MYSTERY BOX | QUESTION 4]\n...the full envelope from step 5..."}
""");
System.out.println(r);
r = call("POST", "/estimate", {"turn": "[MYSTERY BOX | QUESTION 4]\n...the full envelope from step 5..."})
puts r
$r = call("POST", "/estimate", json_decode('{"turn": "[MYSTERY BOX | QUESTION 4]\n...the full envelope from step 5..."}', true));
print_r($r);
var r = await Call("POST", "/estimate", @"{""turn"": ""[MYSTERY BOX | QUESTION 4]\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 $MYSTERY_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 the whole case, every time, plus a briefing for the one person being voiced:

[MYSTERY BOX | QUESTION 4]
MODE: you are voicing one person in a fixed murder case. The case file below is authoritative and
you did not write it.
CASE: MB-0-16 - Ashgrove Hall
SETTING: A large cold English country house in the years between the wars.
HOUSE STYLE: Keep the manner formal and a little starched. Nobody swears.
VICTIM: Mrs Hester Ashgrove-Lyne, the mistress of the house. She knew what everyone in the house
was hiding and had said so at dinner.
FOUND: in the conservatory.
THE WINDOW: the half hour before midnight (23:30 to midnight).
EVERYONE IN THIS CASE (the list is complete - there is nobody else, and you may not add one):
  - Rosalind Cair, a painter staying for the winter - says she was in the gun room
  - Verity Ashgrove-Lyne, the elder daughter of the house - says she was in the muniment room
  - Corin Halberd, the chauffeur - says he was in the library
  - Mrs Ottoline Frayne, the housekeeper of thirty years - says she was in the morning room
  - Juniper Wex, a cousin nobody invited - says they were in the kitchen passage
WHAT THE DETECTIVE HAS ESTABLISHED (authoritative - never contradict it):
  - [C11] The conservatory leaves a mark on anyone who has spent time there, and Verity
    Ashgrove-Lyne is carrying that mark.
  - [C12] The deed box contradicts Verity Ashgrove-Lyne outright: it has her nowhere near the
    muniment room during the half hour before midnight.
YOU ARE PLAYING: Verity Ashgrove-Lyne, the elder daughter of the house (she).
MANNER: measured, and used to being believed.
WHAT VERITY ASHGROVE-LYNE SAYS ABOUT THE WINDOW: she was in the muniment room for the whole of the
half hour before midnight. This is the account. Do not change it.
WHAT IS ACTUALLY TRUE OF VERITY ASHGROVE-LYNE (never volunteer this): she went to the conservatory
during the half hour before midnight.
TIMES PRESSED SO FAR: 0.
YOU MAY CONCEDE THESE AND NOTHING ELSE - only if the question genuinely earns it:
  - [C13] Pressed on the muniment room, Verity Ashgrove-Lyne describes it as it was yesterday
    rather than as it was during the half hour before midnight.
THE LAST FEW ANSWERS IN THIS CASE:
  [Q3 to Mrs Ottoline Frayne | asked: who else was in the passage?] I would rather not say...
THE DETECTIVE PUTS THIS TO YOU: [C12] The deed box contradicts you outright.
THE DETECTIVE ASKS: describe that room to me as it was that night.
INSTRUCTION: answer as Verity Ashgrove-Lyne, with that evidence in front of you. Reply with SAY
and WHERE. If - and only if - this evidence is what finally forces one of the concessions listed
above, name it on a REVEAL line by its bracketed id.

The concession list is the whole safety mechanism. You compute it, not the model: a clue whose prerequisites you already hold, sourced from this suspect, not yet found. Because the list is exact, the model's job shrinks from “decide what is true” to “decide whether the question earned it”, and a REVEAL outside the list is trivially detectable rather than being a plausible new fact you have no way to check.

Keep it bounded. The app budgets the whole envelope to 5,000 characters and, when a long investigation would exceed that, walks a fixed seven-rung ladder: the setting frame first, then the house style, then older answers, then the roster's role descriptions, then the manner line. The case identity, the victim, the window, the full roster with every stated alibi, the briefing, the concession list and the detective's question are on no rung of that ladder. Give up atmosphere, never authority.

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 $MYSTERY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"content": "[MYSTERY BOX | QUESTION 4]\n...the full envelope from step 5..."}'
r = call("POST", "/sessions/{session_id}/messages", body={
      "content": "[MYSTERY BOX | QUESTION 4]\n...the full envelope from step 5..."
    })
reply = r["text"]
print(reply)
const r = await call("POST", "/sessions/{session_id}/messages", {
    "content": "[MYSTERY BOX | QUESTION 4]\n...the full envelope from step 5..."
  });
const reply = r["text"];
console.log(reply);
r, err := call("POST", "/sessions/{session_id}/messages", []byte(`{"content": "[MYSTERY BOX | QUESTION 4]\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": "[MYSTERY BOX | QUESTION 4]\n...the full envelope from step 5..."}
""");
System.out.println(r);
r = call("POST", "/sessions/{session_id}/messages", {"content": "[MYSTERY BOX | QUESTION 4]\n...the full envelope from step 5..."})
puts r
$r = call("POST", "/sessions/{session_id}/messages", json_decode('{"content": "[MYSTERY BOX | QUESTION 4]\n...the full envelope from step 5..."}', true));
print_r($r);
var r = await Call("POST", "/sessions/{session_id}/messages", @"{""content"": ""[MYSTERY BOX | QUESTION 4]\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 $MYSTERY_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -d '{"stream":true,"content":"[MYSTERY BOX | 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. SAY and SCENE are the only labels whose value may contain blank lines.

SAY: The muniment room. As I said at one in the morning, and again at four.

It is cold and it smells of dust and there is a lamp on the table that somebody has
moved. I was reading the tenancy schedules. You may check them if you like - I turned
down a corner, which my mother would have called vandalism.
WHERE: the muniment room
TELL: She says it evenly, and does not look at the deed box.
REVEAL: C13
MOOD: composed
LabelMeaning
SAYWhat the person says, first person. The only label on a question turn whose value may span several lines and contain blank lines - those blank lines are the paragraph breaks.
WHERERequired. The room this person names for the murder window, or declines. Checked against your case file: a different room is a rewritten alibi and the reply is discarded.
TELLOne line of narration about how they say it.
REVEALComma-separated clue ids conceded. Refused unless the id is on the concession list you sent for this suspect this turn.
DENYOne line naming what they refuse to discuss.
MOODA single word for the temperature of the answer.
NOTEOne short out-of-scene clarification. Rare.
SCENEThe closing scene. Only on a close turn, and never alongside SAY.

Then run the four checks that keep the case yours. Each of these is a reply the app discards and re-asks rather than showing:

Softer disagreements — a place name the file does not have, a colleague placed somewhere the roster does not, a repeated answer — are worth surfacing to the reader but do not need the turn thrown away.

Turn kinds and their required labels

KindWhenRequired labels
askAn ordinary question with no evidence produced. Send no concession list entry expectations - a REVEAL here is dropped.SAY, WHERE
confrontA specific found clue is put to the suspect. The only turn on which a REVEAL is honoured.SAY, WHERE
closeThe denouement, over a verdict you have already settled.SCENE

There is no open turn and no model call for the case itself. Generating the case, reading the file and searching rooms are all caller-side and cost nothing; the model is only ever asked to be a person in a chair.

9. Close the session — DELETE /sessions/{id}

Do this when a case closes. Sessions cap at twenty live per user, and leaking them eventually means you cannot start a new case at all.

curl -s -X DELETE "https://api.skillsafe.ai/v1/app-api/sessions/{session_id}" \
  -H "Authorization: Bearer $MYSTERY_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 case as one row in a declared investigations collection (acl_read: owner, acl_write: user), created on the first find and updated on every later change. Three details that cost real debugging time:

The row holds the whole case file as well as the found map. The case could be rebuilt from its seed alone — the generator is deterministic and the case code is the seed — but what cannot be rebuilt is which clues this player found, in what order, and what each suspect has already said.