Drive Red String from your own code
The metered task is available over HTTP. A natural use is a writing app, an outlining tool or a writing-group bot that already holds the outline: your code sends the chapters, the suspects and the clues, and gets back the fair-play review - a verdict (fair, thin or cheats), the suspect a careful reader would guess, a suspicion score for every suspect, a strength for every clue and the plants, by chapter, that would make the solution earnable.
The split first: the page does the counting; the model judges and writes; the page checks it. The facts (which clues point at the culprit and whether each is earnable, off the page or too late, how many on-page clues point at each suspect, whether the culprit walks on after the first third, whether every real clue is crammed into the last quarter) come from casekit.js, which runs in node as well as in the browser. Its checkReview checks a reply before anything is pinned: every suspect and clue reviewed once, ids and citations that exist, plants that land before the reveal on a real suspect, and a verdict that agrees with the count. The thresholds are genre rules of thumb, not laws.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"red-string"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare: never wrap it as {"input": {…}}.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or task is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: the audit task is metered and needs a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
Every run sends task. There is one task, audit; a missing or unknown task is treated as audit.
| task | Does | Reply |
|---|---|---|
audit | Judges whether a careful reader could earn the solution before the reveal, and what to plant where. | verdict (fair - at least two earnable clues that really implicate the culprit, culprit on stage early, nothing needed kept off the page; thin - earnable but weak: one real clue, a late culprit, crammed clues or a suspect with no clue; cheats - no earnable clue, a solution resting on an off-page or reveal-chapter clue, or a culprit who first appears at the reveal), verdict_why, title, reader_guess {id, why, cite}, suspects [{id, suspicion 0-5, note, cite}], clues [{id, strength, note, cite}] with strength one of strong, weak, none, plants [{chapter, points, text, why, cite}] (at most 4, chapters before the reveal), tips, caveats. |
Cites are the fact ids in facts plus N-premise (the writer's premise). The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (CaseKit.toWire does this). The schema requires task, chapters, reveal, culprit, suspects, clues and facts; title, premise and retry_note are optional. A missing required field comes back as a warning from /estimate.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | audit. |
title | string | The working title; may be empty. |
premise | string | The set-up and the solution in the writer's words; past 3,000 characters the middle is cut around a marker. |
chapters | number | 3 to 80. |
reveal | number | The chapter where the detective names the culprit. |
culprit | string | The culprit's id and name, e.g. S4 Felix Grey. |
suspects | string | One per line: S1 | Captain Ruth Okafor | the ferry captain | motive: ... | first appears ch 1 | innocent. |
clues | string | One per line: C4 | ch 16 | The till roll is short by ... | points at S4 | on the page. |
facts | string (JSON) | [{id, text}] from CaseKit.facts: F-case, F-culprit, F-S1 … one per suspect, F-C1 … one per clue, F-earnable, F-obvious, and F-crammed, F-dead or F-empty when they apply. |
retry_note | string | Only on a reformat retry. |
The reply
The job's output.output is a string holding one JSON object. Parse it with CaseKit.parseReply, then normalize(reply), then checkReview(reply, kase), which returns {notes, holding, emptyClues, pinnable, analysis} - the notes (ok, warn, bad), how many earnable clues the reviewer says really hold up, and how many plants can be pinned. applyPlants(kase, reply) returns the board with those plants added as proposed clues.
Worked examples
Replies produced against the app's prompt for the sample cases (made up). Between them they cover all three verdicts. The page replays each of them free from its sample buttons; the full replies ship in example-result.js. Long fields are folded.
task: audit - The greenhouse will - fair
Input:
{
"task": "audit",
"title": "The Greenhouse Will",
"premise": "A wealthy old man is found dead in his greenhouse the morning after rewriting his will. Snowed-in country house, four people inside, a retired schoolteacher as the detective. The doctor poisoned him with his own heart pills to stop him reporting the prescriptions she had been forging.",
"chapters": 24,
"reveal": 22,
"culprit": "S3 Dr Ines Hale",
"suspects": "S1 | Margaret Ashby | the widow | motive: the new will leaves her nothing | first appears ch 1 | innocent\nS2 | Tom Ashby | the nephew, deep in debt | motive: inherits the estate | first appears ch 2 | innocent\n... 2 more lines",
"clues": "C1 | ch 3 | Hale arrives saying she walked from the surgery, but her shoes are dry after the downpour | points at S3 | on the page\nC2 | ch 5 | Betting slips in the study wastepaper basket | points at S2 | on the page\n... 6 more lines",
"facts": "[{\"id\": \"F-case\", \"text\": \"24 chapters; the detective names the culprit in chapter 22; 4 suspects, 8 clues.\"}, {\"id\": \"F-culprit\", \"text\": \"The culprit is S3 Dr Ines Hale, first on stage in chapter 1 - inside the first third (by chapter 8).\"}, {\"id\": \"F-S1\", \"text\": \"S1 Margaret Ashby: first appears in chapter 1; 1 on-page clue before the reveal points at them; motive: the new will leaves her nothing.\"}, \"... 13 more facts\"]"
}
Reply:
{
"task": "audit",
"verdict": "fair",
"verdict_why": "Three separate on-page facts really implicate Hale before chapter 22 - the dry shoes, the missing prescription sheets, and her unprompted knowledge of the crushed pills - and she has been on stage since chapter 1, with nothing the solution needs held off the page. The five red herrings spread suspicion onto Tom, Margaret and Petra by ordinary, innocent-looking means, and no single suspect runs away with the clue count, so the case is close without being hidden.",
"title": "The Greenhouse Will",
"reader_guess": {
"id": "S3",
"why": "By chapter 15 Hale has lied about her alibi, is missing prescription sheets, and names a detail of the poisoning she was never told; Tom's betting slips, the stray key and his midnight lie make him a close second, but none of his clues actually prove involvement.",
"cite": [
"F-C1",
"F-C4",
"F-C6",
"F-S2",
"F-obvious"
]
},
"suspects": [
{
"id": "S1",
"suspicion": 2,
"note": "Margaret has a clear motive and one suspicious on-page act, but burning letters never ties her to the poisoning itself.",
"cite": [
"F-S1",
"F-C5"
]
},
{
"id": "S2",
"suspicion": 4,
"note": "Tom has a strong financial motive and three on-page clues - the betting slips, Petra's key in his pocket and his midnight lie - that stack up to make him look like the obvious culprit.",
"cite": [
"F-S2",
"F-C2",
"F-C7",
"F-C8"
]
},
"... 2 more"
],
"clues": [
{
"id": "C1",
"strength": "strong",
"note": "Hale claims she walked through the downpour, but dry shoes prove that story false, and a doctor caught lying about her whereabouts is a direct mark against her.",
"cite": [
"F-C1"
]
},
{
"id": "C2",
"strength": "weak",
"note": "Betting slips confirm the money troubles the reader already knows about, but discarded slips don't place Tom at the greenhouse or prove he acted.",
"cite": [
"F-C2"
]
},
"... 6 more"
],
"plants": [],
"tips": [
"Plant physical contradictions like the dry shoes early and let the detective simply note them on the page without comment, so a sharp reader can do the deduction ahead of the reveal.",
"Give every red herring an honest, human reason once it resolves - a debt, an embarrassment, a private errand - so the reader feels misled fairly rather than cheated.",
"... 2 more"
]
}
task: audit - Last ferry out - thin
Input:
{
"task": "audit",
"title": "Last Ferry Out",
"premise": "A storm crossing on the last ferry of the season. A businessman goes over the rail in the night and the island constable, who happens to be aboard, has until landfall. The purser pushed him after the businessman found the skimmed fares.",
"chapters": 20,
"reveal": 19,
"culprit": "S4 Felix Grey",
"suspects": "S1 | Captain Ruth Okafor | the ferry captain | motive: the victim was going to report her drinking | first appears ch 1 | innocent\nS2 | Leo Marsh | the victim's business partner | motive: the partnership insurance | first appears ch 2 | innocent\n... 2 more lines",
"clues": "C1 | ch 3 | An empty bottle rolls out from under the wheelhouse chart table | points at S1 | on the page\nC2 | ch 6 | Leo phones the insurer the morning after the crossing | points at S2 | on the page\n... 3 more lines",
"facts": "[{\"id\": \"F-case\", \"text\": \"20 chapters; the detective names the culprit in chapter 19; 4 suspects, 5 clues.\"}, {\"id\": \"F-culprit\", \"text\": \"The culprit is S4 Felix Grey, first on stage in chapter 11 - after the first third (rule of thumb: by chapter 7).\"}, {\"id\": \"F-S1\", \"text\": \"S1 Captain Ruth Okafor: first appears in chapter 1; 2 on-page clues before the reveal point at them; motive: the victim was going to report her drinking.\"}, \"... 12 more facts\"]"
}
Reply:
{
"task": "audit",
"verdict": "thin",
"verdict_why": "Felix only comes on stage in chapter 11, after the first third, and both clues that truly implicate him land in chapters 16 and 17, the last quarter before the reveal; meanwhile Anya has no on-page clue at all, so one suspect sits on the board with nothing for the reader to weigh.",
"title": "Last Ferry Out",
"reader_guess": {
"id": "S4",
"why": "The till shortfall matching the victim's notebook and the soaked jacket contradicting his own denial are stronger, harder-to-explain-away evidence than Ruth's missing minutes or Leo's insurance call, so a careful reader points to Felix.",
"cite": [
"F-S4",
"F-C4",
"F-C5",
"F-obvious"
]
},
"suspects": [
{
"id": "S1",
"suspicion": 3,
"note": "An empty bottle that rolled out from under the chart table and twenty minutes she can't account for give Ruth both opportunity and a motive to silence the victim, even though neither clue shows her at the rail.",
"cite": [
"F-S1",
"F-C1",
"F-C3"
]
},
{
"id": "S2",
"suspicion": 2,
"note": "Leo's call to the insurer the morning after fits his insurance motive, but it is exactly what an innocent partner would also do, so it raises motive more than suspicion.",
"cite": [
"F-S2",
"F-C2"
]
},
"... 2 more"
],
"clues": [
{
"id": "C1",
"strength": "weak",
"note": "An empty bottle under the chart table points to Ruth's drinking, which fits her motive, but it does not put her at the rail when the victim went over.",
"cite": [
"F-C1"
]
},
{
"id": "C2",
"strength": "weak",
"note": "A phone call to the insurer the next morning fits Leo's insurance motive, but routine business behavior does not prove murder.",
"cite": [
"F-C2"
]
},
"... 3 more"
],
"plants": [
{
"chapter": 5,
"points": "S4",
"text": "Felix is shown going over the till roll and pauses on a figure that does not match, before quickly moving on.",
"why": "Puts the culprit on stage inside the first third and gives the reader an early, quiet reason to watch him instead of meeting him for the first time in chapter 11.",
"cite": [
"F-culprit",
"F-crammed"
]
},
{
"chapter": 10,
"points": "S4",
"text": "During the storm, Felix is seen out on deck near the rail, though he later tells the captain he never went out there.",
"why": "Moves a second Felix clue into the book's middle third and plants the lie earlier, so the case against him isn't assembled entirely in the last quarter.",
"cite": [
"F-crammed",
"F-earnable"
]
},
"... 2 more"
],
"tips": [
"Give the culprit a real scene within the book's first third so the reader has watched him before suspecting him.",
"Move at least one of the clues that implicates the culprit into the book's middle third, so the case doesn't land all at once near the reveal.",
"... 2 more"
],
"caveats": [
"Anya's motive is listed as none; a new clue alone won't make her feel like a real suspect without also giving her a reason to want the victim gone."
]
}
task: audit - The lantern walk - cheats
Input:
{
"task": "audit",
"title": "The Lantern Walk",
"premise": "Halloween night in a small village: the annual lantern walk, a body by the stile, a retired police sergeant who runs the tea room. The schoolteacher killed the blackmailer and slipped back into the children's procession.",
"chapters": 18,
"reveal": 17,
"culprit": "S4 Bea Lowell",
"suspects": "S1 | Reverend Alan Pike | organises the village lantern walk | motive: the victim was selling the church field | first appears ch 1 | innocent\nS2 | June Carrow | the victim's sister | motive: their mother's house | first appears ch 1 | innocent\n... 2 more lines",
"clues": "C1 | ch 4 | Pike argued with the victim at the parish meeting | points at S1 | on the page\nC2 | ch 7 | June's lantern is found by the stile where the body lay | points at S2 | on the page\n... 4 more lines",
"facts": "[{\"id\": \"F-case\", \"text\": \"18 chapters; the detective names the culprit in chapter 17; 4 suspects, 6 clues.\"}, {\"id\": \"F-culprit\", \"text\": \"The culprit is S4 Bea Lowell, first on stage in chapter 2 - inside the first third (by chapter 6).\"}, {\"id\": \"F-S1\", \"text\": \"S1 Reverend Alan Pike: first appears in chapter 1; 2 on-page clues before the reveal point at them; motive: the victim was selling the church field.\"}, \"... 11 more facts\"]"
}
Reply:
{
"task": "audit",
"verdict": "cheats",
"verdict_why": "No earnable clue points at the culprit: the glove that would implicate Bea Lowell is kept off the page in chapter 10, and the one on-page clue against her, her lantern route past the stile, is withheld until chapter 17, the reveal chapter itself, so a careful reader cannot beat the detective to the answer.",
"title": "The Lantern Walk",
"reader_guess": {
"id": "S1",
"why": "Pike collects two on-page clues before the reveal, an argument with the victim over the church field and a lie about locking the church, more than any other suspect, so a careful reader would name him first.",
"cite": [
"F-S1",
"F-C1",
"F-C5",
"F-obvious"
]
},
"suspects": [
{
"id": "S1",
"suspicion": 4,
"note": "Two on-page clues point at him, including a lie about his own timeline, making him look like the prime suspect to a careful reader.",
"cite": [
"F-S1",
"F-C1",
"F-C5"
]
},
{
"id": "S2",
"suspicion": 2,
"note": "Her lantern turning up at the stile is suggestive, but it could just as easily have been dropped during the village's own lantern walk, so it only mildly raises suspicion.",
"cite": [
"F-S2",
"F-C2"
]
},
"... 2 more"
],
"clues": [
{
"id": "C1",
"strength": "weak",
"note": "A public argument about selling the church field shows motive, but it is ordinary village friction, not evidence of murder.",
"cite": [
"F-C1"
]
},
{
"id": "C2",
"strength": "weak",
"note": "A lantern by the stile could simply have been dropped during the walk the whole village takes that night, so it only loosely ties June to the body.",
"cite": [
"F-C2"
]
},
"... 4 more"
],
"plants": [
{
"chapter": 10,
"points": "S4",
"text": "The reader sees what the detective sees on the glove in the schoolroom: a smear of candle wax from a guttering lantern, the kind the children's lanterns use, on Bea's own spare glove.",
"why": "Turns the only clue naming Bea into one the reader can weigh before the reveal, giving the book its first earnable clue against the culprit.",
"cite": [
"F-C3",
"F-earnable"
]
},
{
"chapter": 9,
"points": "S4",
"text": "The reader sees Bea's lantern walk route sheet pinned up in the schoolroom, already marked to pass the stile at nine, the same route the detective reads aloud in chapter 17.",
"why": "Moves the clue that currently only appears in the reveal chapter onto the page early enough for the reader to use it, giving a second earnable clue against the culprit.",
"cite": [
"F-C6",
"F-earnable"
]
},
"... 1 more"
],
"tips": [
"Show the glove's full detail on the page the moment the detective sees it, not only after the reveal, since fair play means letting the reader see what the detective sees.",
"Give the culprit an on-page clue well before the final chapters, so she isn't only a name with a motive until the last pages.",
"... 2 more"
],
"caveats": [
"This audit judges only the clue lines and summaries given, not any fuller manuscript text."
]
}
Build the input yourself
Load the script in node (global.window = {}; require("./casekit.js")), then:
const K = CaseKit;
const { kase } = K.parseCase([
"Chapters: 20", "Reveal: 19",
"Suspect: Ruth | the captain | ch 1", "Suspect: Felix | the purser | ch 11 | culprit",
"Clue: ch 16 | The till roll is short | -> Felix"
].join("\n"));
const { input } = K.buildInput(kase);
const wire = K.toWire(input); // POST this to /estimate and /run
// later: const n = K.normalize(K.parseReply(job.output.output));
// const { notes, pinnable } = K.checkReview(n, kase); K.applyPlants(kase, n).kase -> the board with the plants pinned
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://red-string.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://red-string.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// red-string.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://red-string.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://red-string.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// RedString.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class RedString {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://red-string.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://red-string.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://red-string.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://red-string.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"red-string"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "red-string"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "red-string" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "red-string"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "red-string"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "red-string" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "red-string"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "red-string" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "audit", "title": "...", "premise": "...", "chapters": 20, "reveal": 19, "culprit": "S4 Felix Grey", "suspects": "S1 | ...", "clues": "C1 | ch 3 | ...", "facts": "[...]"}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "audit", "title": "...", "premise": "...", "chapters": 20, "reveal": 19, "culprit": "S4 Felix Grey", "suspects": "S1 | ...", "clues": "C1 | ch 3 | ...", "facts": "[...]"}
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "audit", title, premise, chapters, reveal, culprit, suspects, clues, facts}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the task and the input: a retry with the same key never bills twice. The page uses red-string:audit:<inputHash>:a<attempt>, and its one reformat retry (with retry_note added) is attempt 2 - a new key and a second, named run. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="red-string:audit:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "red-string:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the lane's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict") or reply.get("style_prompt"))
import { createHash } from "node:crypto";
const key = `red-string:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.verdict || reply.style_prompt);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "red-string:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "red-string:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply);
require "digest"
key = "red-string:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["title"]].inspect
$key = "red-string:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["title"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"red-string:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: red-string:audit:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "audit", "title": "...", "premise": "...", "chapters": 20, "reveal": 19, "culprit": "S4 Felix Grey", "suspects": "S1 | ...", "clues": "C1 | ch 3 | ...", "facts": "[...]"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "red-string:audit:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `red-string:audit:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "red-string:audit:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "red-string:audit:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "red-string:audit:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: red-string:audit:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"red-string:audit:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);