Drive Two Ways Out from your own code
The one metered task, premortem, is available over HTTP. It takes a home fire escape plan - rooms with their level, door route, window and smoke alarm; people with the room they sleep in, what they need and who helps them; the household's answers on alarms, drills and the meeting spot - together with the page's own findings, and returns a verdict (rework, patch or drill), three to six ways the plan could fail at night (each tied to a finding or to the notes, with a severity and one concrete fix), a practice-drill script for the household, one to three open questions, and one thing the plan already does well. A natural use is a home-safety checklist, a landlord's move-in pack or a family organiser that keeps a plan and wants it stress-tested.
The division of labour first: the page owns the checks; the model owns the judgement and the words; the page re-checks the reply. Counting each room's ways out, matching people to rooms and helpers, the blockers and gaps, the verdict rule and the rough escape clock come from waykit.js, which runs in node as well as in the browser. WayKit.check holds each reply to them: the verdict by the rule, a failure for every blocker marked high, real finding ids whose room or person matches, failures from notes never high, and no number, capitalised name, he/she or internal id that the input does not hold.
Get a token on the token page (step 2 below also shows how to mint a guest token in code).
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":"two-ways-out"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field (task, rooms, people, household, facts, figures) 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 premortem 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 and the verdict rule
Every run sends task: premortem (the only task; a missing or unknown one is treated as premortem). The verdict is decided by a fixed rule on the page's figures, checked in this order - the first that applies wins:
- rework - the page found 1 or more blockers (a slept-in room with no usable way out, a person who needs help with nobody named to help, no smoke alarm confirmed anywhere).
- patch - the page found 1 or more gaps (one way out, no alarm inside a slept-in room, a child nobody is named to wake, a helper on another floor, a person not matched to a room, no alarm confirmed on every level, alarms not tested this month, no meeting spot, no drill in the last six months).
- drill - everything else.
The declared input schema carries scalars only, so the array- and object-valued fields travel JSON-encoded as strings (WayKit.toWire does this). Every value is a string.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | premortem. Required. |
rooms | string (JSON) | [{id, name, level, door, window, alarm, ways_out, sleepers}] - ids R1.., up to 12; level, door, window, alarm in plain words; ways_out the page's count; sleepers person ids. Required. |
people | string (JSON) | [{id, name, room, needs, helper}] - ids P1.., up to 10; room and helper are ids or empty; needs in plain words. Required. |
household | string (JSON) | {alarm_every_level, alarms_last_tested, last_drill, timed_drill_seconds, meeting_spot}, plain words. Required. |
facts | string (JSON) | [{id, level, ref, text}] - the page's findings, ids F1..; level is blocker or gap; ref a room id, a person id or H. Required (may be []). |
figures | string (JSON) | {blockers, gaps, rooms, sleeping_rooms, people}, every value a string. Required. |
notes | string | Free text from the household, up to 300 characters. |
clip_note, retry_note | string | What the page cut, when it cut something; and only on a reformat retry, what was wrong. |
The reply
The job's output.output is a string holding one JSON object: task, verdict, title, summary, failures (3-6 {fact, ref, story, severity, fix}, severity high for blockers, medium for gaps, medium or low for failures from the notes alone), drill (4-7 strings), questions (1-3 strings) and keep. Parse with WayKit.parseReply, then normalize(reply), then check(reply, built) where built = WayKit.buildInput(form); md(reply, built, form) writes the Markdown, csv(built) the room sheet and ics(date, meetingSpot) the reminders calendar.
Worked examples
Replies produced against the app's prompt for the three sample homes (made up; long fields shortened; the full replies ship in example-result.js). Together they cover every verdict.
a family house where someone who needs help has no helper - rework
Input:
{
"task": "premortem",
"rooms": "[{\"id\":\"R1\",\"name\":\"Main bedroom\",\"level\":\"one floor up\",\"door\":\"a door to a hall or stairs that lead outside\",\"window\":\"an upper window with no ladder\",\"alarm\":\"a smoke alarm inside\",\"ways_out\":1,\"sleepers\":[\"P1\",\"P2\"]},{\"id\":\"R2\",\"name\":\"Kids' room\",\"level\":\"one floor up\",\"door\":\"a door to a hall or stairs that lead outside\",\"window\":\"an upper window with no ladder\",\"alarm\":\"no smoke alarm inside\",\"ways_out\":1,\"sle ... (1109 characters)",
"people": "[{\"id\":\"P1\",\"name\":\"Dana\",\"room\":\"R1\",\"needs\":\"gets out alone\",\"helper\":\"\"},{\"id\":\"P2\",\"name\":\"Sam\",\"room\":\"R1\",\"needs\":\"gets out alone\",\"helper\":\"\"},{\"id\":\"P3\",\"name\":\"Theo\",\"room\":\"R2\",\"needs\":\"a child who may sleep through an alarm\",\"helper\":\"P1\"},{\"id\":\"P4\",\"name\":\"Ivy\",\"room\":\"R2\",\"needs\":\"a child who may sleep through an alarm\",\"helper\":\"\"},{\"id\":\"P5\",\"name\":\"Ruth\",\"room\":\"R3\",\"needs\":\"needs help to get out (a ... (554 characters)",
"household": "{\"alarm_every_level\":\"not sure\",\"alarms_last_tested\":\"more than six months ago\",\"last_drill\":\"never\",\"timed_drill_seconds\":\"\",\"meeting_spot\":\"\"}",
"facts": "[{\"id\":\"F1\",\"level\":\"blocker\",\"ref\":\"P5\",\"text\":\"Ruth needs help to get out and nobody is named to help.\"},{\"id\":\"F2\",\"level\":\"gap\",\"ref\":\"R1\",\"text\":\"Main bedroom has one way out (the door; an upper window with no ladder).\"},{\"id\":\"F3\",\"level\":\"gap\",\"ref\":\"R2\",\"text\":\"Kids' room has one way out (the door; an upper window with no ladder).\"},{\"id\":\"F4\",\"level\":\"gap\",\"ref\":\"R4\",\"text\":\"Basement bedroom has one way out ... (1337 characters)",
"figures": "{\"blockers\":\"1\",\"gaps\":\"11\",\"rooms\":\"5\",\"sleeping_rooms\":\"4\",\"people\":\"6\"}",
"notes": "The front door deadbolt needs a key; the key hangs on a hook by the door. Ruth uses a walker. The basement window was painted shut years ago.",
"clip_note": ""
}
Reply:
{
"task": "premortem",
"verdict": "rework",
"title": "Ruth has no named helper - fix that first",
"summary": "This plan needs rework, with 1 blocker and 11 gaps to address. The most urgent step is to name someone who can help Ruth get out of the Den.",
"failures": [
{
"fact": "F1",
"ref": "P5",
"story": "Ruth uses a walker and needs help to get out of the Den, but no one is named to help them get out safely.",
"severity": "high",
"fix": "Name a person to wake and help Ruth get out of the Den."
},
{
"fact": "F8",
"ref": "P4",
"story": "Ivy may sleep through the smoke alarm in the Kids' room, and nobody is named to wake them.",
"severity": "medium",
"fix": "Name someone to wake Ivy if the alarm sounds, so they do not sleep through it."
},
{
"fact": "F5",
"ref": "R2",
"story": "The Kids' room where Theo and Ivy sleep has no smoke alarm inside, so a fire there might not wake them in time.",
"severity": "medium",
"fix": "Put a smoke alarm inside the Kids' room where Theo and Ivy sleep."
},
{
"fact": "F7",
"ref": "R4",
"story": "The Basement bedroom where Marco sleeps has no smoke alarm inside, so smoke might spread before Marco wakes.",
"severity": "medium",
"fix": "Put a smoke alarm inside the Basement bedroom where Marco sleeps."
},
{
"fact": "F4",
"ref": "R4",
"story": "The Basement bedroom has only the door as a way out, since the window was painted shut, so Marco could be trapped if the door is blocked.",
"severity": "medium",
"fix": "Free the painted-shut window in the Basement bedroom so Marco has a second way out."
},
{
"fact": "",
"ref": "H",
"story": "The front door needs a key kept on a hook by the door, so finding it in smoke and dark could slow everyone's way out.",
"severity": "medium",
"fix": "Make sure everyone in the household knows exactly where the key is kept."
}
],
"drill": [
"Whoever notices smoke or hears the alarm first wakes the rest of the household right away.",
"Dana and Sam leave the Main bedroom through the door.",
"In the Kids' room, Dana helps Theo out the door; choose someone to wake and help Ivy the same way.",
"In the Den, choose someone to help Ruth out the door or the ground-level window.",
"Marco leaves the Basement bedroom through the door.",
"Everyone meets at a spot outside that you choose before the drill.",
"Time how long the whole drill takes, since there is no time to beat yet."
],
"questions": [
"Who will be named to help Ruth get outside if nobody is assigned yet?",
"Who will wake Ivy if they do not hear the alarm?",
"Where outside will everyone meet after getting out of the house?"
],
"keep": "Dana is already named to help Theo get out of the Kids' room."
}
a shared top-floor flat with one bedroom on one way out - patch
Input:
{
"task": "premortem",
"rooms": "[{\"id\":\"R1\",\"name\":\"Front bedroom\",\"level\":\"two floors up\",\"door\":\"a door to a hall or stairs that lead outside\",\"window\":\"an upper window with no ladder\",\"alarm\":\"a smoke alarm inside\",\"ways_out\":1,\"sleepers\":[\"P1\"]},{\"id\":\"R2\",\"name\":\"Back bedroom\",\"level\":\"two floors up\",\"door\":\"a door to a hall or stairs that lead outside\",\"window\":\"an upper window with an escape ladder\",\"alarm\":\"a smoke alarm inside\",\"ways_out\": ... (653 characters)",
"people": "[{\"id\":\"P1\",\"name\":\"Priya\",\"room\":\"R1\",\"needs\":\"gets out alone\",\"helper\":\"\"},{\"id\":\"P2\",\"name\":\"Jonah\",\"room\":\"R2\",\"needs\":\"gets out alone\",\"helper\":\"\"}]",
"household": "{\"alarm_every_level\":\"yes\",\"alarms_last_tested\":\"one to six months ago\",\"last_drill\":\"never\",\"timed_drill_seconds\":\"\",\"meeting_spot\":\"the bus shelter across the street\"}",
"facts": "[{\"id\":\"F1\",\"level\":\"gap\",\"ref\":\"R1\",\"text\":\"Front bedroom has one way out (the door; an upper window with no ladder).\"},{\"id\":\"F2\",\"level\":\"gap\",\"ref\":\"H\",\"text\":\"The smoke alarms were last tested one to six months ago; the advice is once a month.\"},{\"id\":\"F3\",\"level\":\"gap\",\"ref\":\"H\",\"text\":\"The household has never done a practice drill; the advice is twice a year.\"}]",
"figures": "{\"blockers\":\"0\",\"gaps\":\"3\",\"rooms\":\"3\",\"sleeping_rooms\":\"2\",\"people\":\"2\"}",
"notes": "A shared flat at the top of the building. There is one stairwell. Jonah sleeps with earplugs in.",
"clip_note": ""
}
Reply:
{
"task": "premortem",
"verdict": "patch",
"title": "Three gaps to close, starting with Front bedroom",
"summary": "This plan needs a patch - there are no blockers, but three gaps could trip you up at night. Make sure Front bedroom has a real second way out, since its window currently has no ladder.",
"failures": [
{
"fact": "F1",
"ref": "R1",
"story": "Front bedroom's only other way out is an upper window with no ladder, so Priya may not have a second way out if the door route isn't usable.",
"severity": "medium",
"fix": "Get an escape ladder for Front bedroom's window so it becomes a real second way out."
},
{
"fact": "F2",
"ref": "H",
"story": "The smoke alarms were last tested one to six months ago, so you can't be sure every one of them would still sound tonight.",
"severity": "medium",
"fix": "Test every smoke alarm now, then keep testing monthly going forward."
},
{
"fact": "",
"ref": "P2",
"story": "Jonah sleeps with earplugs in, so the smoke alarm might not be loud enough to wake Jonah up.",
"severity": "medium",
"fix": "Make sure Jonah can feel or see an alert, not just hear the alarm, since earplugs are worn at night."
},
{
"fact": "F3",
"ref": "H",
"story": "The household has never run a practice drill, so no one yet knows this plan well enough to follow it quickly at night.",
"severity": "medium",
"fix": "Pick a night soon and walk through the whole plan together, from alarm to meeting spot."
}
],
"drill": [
"Whoever notices smoke or hears the alarm first wakes everyone else right away.",
"Priya leaves Front bedroom by the door, since the window has no ladder.",
"Jonah leaves Back bedroom by the door, or the window using the escape ladder.",
"During the drill, check that Jonah notices the alarm despite wearing earplugs.",
"Everyone meets at the bus shelter across the street once outside.",
"Time the whole drill from alarm to meeting spot and write it down."
],
"questions": [
"If the one stairwell can't be used, is there another way down for either bedroom?",
"How would Jonah be woken if the alarm doesn't carry through the earplugs?"
],
"keep": "Back bedroom already has two ways out, and the household has a meeting spot picked: the bus shelter across the street."
}
a single-storey house with nothing for the page to fix - drill
Input:
{
"task": "premortem",
"rooms": "[{\"id\":\"R1\",\"name\":\"Our room\",\"level\":\"ground level\",\"door\":\"a door to a hall or stairs that lead outside\",\"window\":\"a window you can climb out of at ground level\",\"alarm\":\"a smoke alarm inside\",\"ways_out\":2,\"sleepers\":[\"P1\",\"P2\"]},{\"id\":\"R2\",\"name\":\"Nursery\",\"level\":\"ground level\",\"door\":\"a door to a hall or stairs that lead outside\",\"window\":\"a window you can climb out of at ground level\",\"alarm\":\"a smoke alarm ins ... (681 characters)",
"people": "[{\"id\":\"P1\",\"name\":\"Lena\",\"room\":\"R1\",\"needs\":\"gets out alone\",\"helper\":\"\"},{\"id\":\"P2\",\"name\":\"Omar\",\"room\":\"R1\",\"needs\":\"gets out alone\",\"helper\":\"\"},{\"id\":\"P3\",\"name\":\"Noor\",\"room\":\"R2\",\"needs\":\"needs help to get out (a baby, or a mobility, sight or hearing need)\",\"helper\":\"P1\"}]",
"household": "{\"alarm_every_level\":\"yes\",\"alarms_last_tested\":\"this month\",\"last_drill\":\"in the last six months\",\"timed_drill_seconds\":\"95\",\"meeting_spot\":\"the big oak by the mailbox\"}",
"facts": "[]",
"figures": "{\"blockers\":\"0\",\"gaps\":\"0\",\"rooms\":\"3\",\"sleeping_rooms\":\"2\",\"people\":\"3\"}",
"notes": "Single-storey house. The baby monitor is on Lena's side of the bed. The back door sticks in wet weather.",
"clip_note": ""
}
Reply:
{
"task": "premortem",
"verdict": "drill",
"title": "Ready to drill - keep the habits going",
"summary": "This plan has no blockers and no gaps, so it is ready to practise. Run the drill soon so Lena, Omar, and Noor all know the steps by heart.",
"failures": [
{
"fact": "",
"ref": "H",
"story": "If the back door sticks in wet weather, whoever reaches it at night could be delayed trying to force it open instead of getting outside quickly.",
"severity": "medium",
"fix": "Check and fix the back door so it opens easily in wet weather, or plan to use a window instead."
},
{
"fact": "",
"ref": "P1",
"story": "Because the baby monitor sits only on Lena's side of the bed, if Lena doesn't stir right away at night, no one else nearby may hear Noor.",
"severity": "medium",
"fix": "Add a second monitor or alert on Omar's side too, so either of you can hear Noor overnight."
},
{
"fact": "",
"ref": "H",
"story": "A plan that depends on alarms tested this month and a recent drill could quietly go stale if the household doesn't keep repeating them.",
"severity": "low",
"fix": "Put a reminder on the calendar to re-test alarms monthly and repeat the practice drill every six months."
}
],
"drill": [
"Sound the smoke alarm to start the drill, as if it had gone off.",
"Lena and Omar wake right away in Our room and prepare to leave.",
"Omar leaves Our room alone, using its door or the window.",
"Lena goes to the Nursery to wake Noor and help Noor get ready to leave.",
"Lena helps Noor leave the Nursery, using its door or the window.",
"Everyone meets at the big oak by the mailbox.",
"Time the drill and try to beat 95 seconds."
],
"questions": [
"If Lena is the one who doesn't wake first, who helps Noor out of the Nursery?",
"If the back door sticks, does everyone know to use a window instead?",
"Where exactly outside is the big oak by the mailbox, so everyone finds it in the dark?"
],
"keep": "Every bedroom already has two ways out, alarms were tested this month, and Noor already has a named helper, Lena."
}
Build the input yourself
Load the script in node (global.window = global; require("./waykit.js")), then:
const K = WayKit;
const form = K.form({
rooms: [{ name: "Bedroom", level: "2", door: "yes", window: "high", alarm: "yes" }],
people: [{ name: "Alex", room: "Bedroom", needs: "none", helper: "" }],
every_level: "yes", tested: "month", drill: "never", meet: "the corner mailbox", notes: "" });
const r = K.read(form); // r.rooms (ways out), r.facts (blockers and gaps), r.fig (counts, verdict), r.clock (rough seconds)
const built = K.buildInput(form);
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes // the page's re-check of the reply
// K.csv(built); K.ics(new Date(), form.meet) // the room sheet and the reminders calendar
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://two-ways-out.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://two-ways-out.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"]
// two-ways-out.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://two-ways-out.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://two-ways-out.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
}
// TwoWaysOut.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 TwoWaysOut {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://two-ways-out.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://two-ways-out.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://two-ways-out.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://two-ways-out.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":"two-ways-out"}' | 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": "two-ways-out"})["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: "two-ways-out" })).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": "two-ways-out"}, 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", "two-ways-out"), 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: "two-ways-out" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "two-ways-out"])["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 = "two-ways-out" }))!["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": "premortem", "rooms": "[{\"id\":\"R1\",\"name\":\"Front bedroom\",\"level\":\"two floors up\",\"door\":\"a door to a hall or stairs that lead outside\",\"window\":\"an upper window with no ladder\",\"alarm\":\"a smoke alarm inside\",\"ways_out\":1,\"sleepers\":[\"P1\"]}, ...]", "people": "[{\"id\":\"P1\",\"name\":\"Priya\",\"room\":\"R1\",\"needs\":\"gets out alone\",\"helper\":\"\"},{\"id\":\"P2\",\"name\":\"Jonah\",\"room\":\"R2\",\"needs\":\"gets out alone\",\"helper\":\"\"}]", "household": "{\"alarm_every_level\":\"yes\",\"alarms_last_tested\":\"one to six months ago\",\"last_drill\":\"never\",\"timed_drill_seconds\":\"\",\"meeting_spot\":\"the bus shelter across the street\"}", "facts": "[...]", "figures": "{\"blockers\":\"0\",\"gaps\":\"3\",\"rooms\":\"3\",\"sleeping_rooms\":\"2\",\"people\":\"2\"}", "notes": "A shared flat at the top of the building. There is one stairwell. Jonah sleeps with earplugs in."}
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": "premortem", "rooms": "[{\"id\":\"R1\",\"name\":\"Front bedroom\",\"level\":\"two floors up\",\"door\":\"a door to a hall or stairs that lead outside\",\"window\":\"an upper window with no ladder\",\"alarm\":\"a smoke alarm inside\",\"ways_out\":1,\"sleepers\":[\"P1\"]}, ...]", "people": "[{\"id\":\"P1\",\"name\":\"Priya\",\"room\":\"R1\",\"needs\":\"gets out alone\",\"helper\":\"\"},{\"id\":\"P2\",\"name\":\"Jonah\",\"room\":\"R2\",\"needs\":\"gets out alone\",\"helper\":\"\"}]", "household": "{\"alarm_every_level\":\"yes\",\"alarms_last_tested\":\"one to six months ago\",\"last_drill\":\"never\",\"timed_drill_seconds\":\"\",\"meeting_spot\":\"the bus shelter across the street\"}", "facts": "[...]", "figures": "{\"blockers\":\"0\",\"gaps\":\"3\",\"rooms\":\"3\",\"sleeping_rooms\":\"2\",\"people\":\"2\"}", "notes": "A shared flat at the top of the building. There is one stairwell. Jonah sleeps with earplugs in."}
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: "premortem", rooms, people, household, facts, figures, notes, ... (see the field table)}
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 lane and the input: a retry with the same key never bills twice. The page uses two-ways-out:<task>:<inputHash>:a<attempt> (the one task is premortem), and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="two-ways-out:premortem:$(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 = "two-ways-out:" + 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 reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `two-ways-out:${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);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "two-ways-out:" + 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 = "two-ways-out:" + 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 = "two-ways-out:#{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 = "two-ways-out:" . $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 = $"two-ways-out:{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: two-ways-out:premortem:$(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": "premortem", "rooms": "[{\"id\":\"R1\",\"name\":\"Front bedroom\",\"level\":\"two floors up\",\"door\":\"a door to a hall or stairs that lead outside\",\"window\":\"an upper window with no ladder\",\"alarm\":\"a smoke alarm inside\",\"ways_out\":1,\"sleepers\":[\"P1\"]}, ...]", "people": "[{\"id\":\"P1\",\"name\":\"Priya\",\"room\":\"R1\",\"needs\":\"gets out alone\",\"helper\":\"\"},{\"id\":\"P2\",\"name\":\"Jonah\",\"room\":\"R2\",\"needs\":\"gets out alone\",\"helper\":\"\"}]", "household": "{\"alarm_every_level\":\"yes\",\"alarms_last_tested\":\"one to six months ago\",\"last_drill\":\"never\",\"timed_drill_seconds\":\"\",\"meeting_spot\":\"the bus shelter across the street\"}", "facts": "[...]", "figures": "{\"blockers\":\"0\",\"gaps\":\"3\",\"rooms\":\"3\",\"sleeping_rooms\":\"2\",\"people\":\"2\"}", "notes": "A shared flat at the top of the building. There is one stairwell. Jonah sleeps with earplugs in."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "two-ways-out:premortem:" + 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": `two-ways-out:premortem:${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", "two-ways-out:premortem:"+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", "two-ways-out:premortem:" + 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" => "two-ways-out:premortem:#{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: two-ways-out:premortem:" . 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", $"two-ways-out:premortem:{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);