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.
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
| 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 ask a question. /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 case. |
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 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
| Label | Meaning |
|---|---|
SAY | What 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. |
WHERE | Required. 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. |
TELL | One line of narration about how they say it. |
REVEAL | Comma-separated clue ids conceded. Refused unless the id is on the concession list you sent for this suspect this turn. |
DENY | One line naming what they refuse to discuss. |
MOOD | A single word for the temperature of the answer. |
NOTE | One short out-of-scene clarification. Rare. |
SCENE | The 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:
- A rewritten alibi.
WHEREresolves to a room that is not this person's stated one and not a truth they have already conceded. Every deduction in the game runs on the alibi table; a suspect who can revise theirs mid-interview voids every earlier inference. - An unearned confession. Any first-person admission of the killing. If the culprit could crack under pressure there would be no case to solve; if an innocent could, the answer would be wrong.
- An invented person. A titled name that is not one of the five and not the victim. A sixth suspect is a sixth possible culprit and the closed circle is the genre's one promise.
- An unauthorised
REVEAL. An id you did not offer, or one whose prerequisites are unmet. Compare strictly: an exact id, or a verbatim run of the clue's own words at least forty characters long. A fuzzy match here is how invented evidence gets in.
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
| Kind | When | Required labels |
|---|---|---|
ask | An ordinary question with no evidence produced. Send no concession list entry expectations - a REVEAL here is dropped. | SAY, WHERE |
confront | A specific found clue is put to the suspect. The only turn on which a REVEAL is honoured. | SAY, WHERE |
close | The 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:
- 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.
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.