Drive Room in the Tank from your own code
The one metered task, stock, is available over HTTP. It takes the hours a week you are willing to give, the commitments you already keep and the new ask, together with the page's sums and calls, and returns a note per commitment, a note on the new one, the verdict, the commitment to rehome first with a note, a smaller yes where the sums call for one, and a line to say back to whoever asked.
The division of labour first: the page counts; the model writes. The page adds up the hours, works out the shares, makes the call, picks the commitment to rehome first and the smaller yes, and sends all of it as facts. The model copies the calls and puts them into words; the page re-checks every call and every number in the reply.
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": "..." } }
The token is minted for this app (the guest call takes {"slug":"room-in-the-tank"}), 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, willing_hours, commitments, new_ask, asked_by, facts) 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 notes are metered and need a personal sign-in. |
| 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, the input and the calls
Every run sends task: stock (the one task is stock); a missing or different one is still answered as stock. All fields are strings.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always stock. |
willing_hours | yes | The hours a week you are willing to give to standing commitments, in all. |
commitments | yes | One line per thing you already keep, c1: name - 2 h a week - lifts you - flexible - 20% of your hours, up to c8; or none listed. |
new_ask | yes | The new one: name, hours a week, how you guess it would leave you, and its share of your hours. |
asked_by | yes | Who asked, or nobody named. |
facts | yes | The page's sums and calls: hours kept now, hours with the new one, draining hours, verdict, rehome and smaller_yes_hours. |
retry_note | no | Only on a reformat retry: why the previous reply was unusable. |
The call, first match wins: what you keep now is over your hours, overstocked; it fits now but not with the new one, no_room; more than half the hours with the new one drain you, murky; over 85% of your hours, snug; otherwise room. rehome (for overstocked, no_room and murky only) is the flexible commitment that drains you before one that leaves you even before one that lifts you, then the most hours, then the earlier one; for murky, only flexible ones that drain you. smaller_yes_hours (for no_room and snug only) is the most quarter-hours of the new one that keeps you at or under 85%, when that is at least half an hour and less than the full ask.
The reply
One JSON object, no prose around it:
{"task": "stock", "headline": "...", "fish": [{"id": "c1", "note": "..."}, ...], "new_fish": "...", "verdict": "no_room", "rehome": "c4", "rehome_note": "...", "smaller_yes": "...", "reply_line": "..."}
| Field | What it holds |
|---|---|
headline | At most 20 words that fit the verdict. |
fish[] | One per commitment in order: id and a one-sentence note (its hours or share, how it leaves you, fixed or flexible). |
new_fish | The new one's hours, your guess of how it would leave you, and what the week comes to with it. |
verdict | overstocked, no_room, murky, snug or room, copied from facts. |
rehome | The id to rehome first, or an empty string. |
rehome_note | Names that commitment and its hours and whether freeing them is enough on its own; empty when nothing is picked. |
smaller_yes | The new one offered at exactly smaller_yes_hours; empty when there is none. |
reply_line | What you can say back to whoever asked, matching the verdict. |
The page then checks the reply (TankKit.check) - a note for every commitment and none for ones not listed, the verdict and the rehome pick against its own, a rehome note and a smaller yes exactly when the sums call for them, the smaller yes naming the right hours, and no number that is not one of yours - and lists anything it finds under "The page's check".
Worked examples - every verdict
These are the 5 free samples on the page: the exact input the page sends and the reply that replays for free. Together they cover every verdict.
Running the office quiz - already overstocked
Input:
{
"task": "stock",
"willing_hours": "6",
"commitments": "c1: choir rehearsal - 2 h a week - lifts you - flexible - 33% of your hours\nc2: the residents' committee - 3 h a week - drains you - flexible - 50% of your hours\nc3: helping at my sister's cafe - 2 h a week - leaves you even - fixed - 33% of your hours",
"new_ask": "running the office quiz - 1.5 h a week - you guess it leaves you even - 25% of your hours",
"asked_by": "a colleague",
"facts": "willing: 6 h a week\nkept now: 7 h a week, 117% of your hours\nwith the new one: 8.5 h a week, 142% of your hours\ndraining hours with the new one: 3 of 8.5, 35%\nverdict: overstocked\nrehome: c2 (the residents' committee, 3 h a week) - frees 3 h - enough: yes\nsmaller_yes_hours: none"
}
Reply:
{
"task": "stock",
"headline": "Your tank's already overflowing before running the office quiz even gets a look in.",
"fish": [
{
"id": "c1",
"note": "Choir rehearsal takes 2 hours a week, lifts you, and stays flexible."
},
{
"id": "c2",
"note": "The residents' committee drains you for 3 hours a week, though it stays flexible."
},
{
"id": "c3",
"note": "Helping at your sister's cafe takes 2 hours a week, leaves you even, and stays fixed."
}
],
"new_fish": "Running the office quiz would take 1.5 hours a week and you guess it would leave you even, bringing your week to 8.5 hours, 142% of your hours.",
"verdict": "overstocked",
"rehome": "c2",
"rehome_note": "Freeing up the residents' committee's 3 hours a week would be enough on its own.",
"smaller_yes": "",
"reply_line": "I can't take on running the office quiz for now, since my week has no space left."
}
Coaching the under-10s - no room for this one
Input:
{
"task": "stock",
"willing_hours": "10",
"commitments": "c1: book club - 1.5 h a week - lifts you - flexible - 15% of your hours\nc2: Spanish evening class - 2 h a week - leaves you even - fixed - 20% of your hours\nc3: walking a neighbour's dog - 1 h a week - lifts you - flexible - 10% of your hours\nc4: treasurer for the allotment society - 2 h a week - drains you - flexible - 20% of your hours",
"new_ask": "coaching the under-10s football team - 4 h a week - you guess it leaves you even - 40% of your hours",
"asked_by": "the club secretary",
"facts": "willing: 10 h a week\nkept now: 6.5 h a week, 65% of your hours\nwith the new one: 10.5 h a week, 105% of your hours\ndraining hours with the new one: 2 of 10.5, 19%\nverdict: no_room\nrehome: c4 (treasurer for the allotment society, 2 h a week) - frees 2 h - enough: yes\nsmaller_yes_hours: 2"
}
Reply:
{
"task": "stock",
"headline": "Coaching the under-10s football team wouldn't fit into your week as things stand.",
"fish": [
{
"id": "c1",
"note": "Book club lifts you for 1.5 hours a week and stays flexible."
},
{
"id": "c2",
"note": "Your Spanish evening class takes 2 hours a week, leaves you even, and is fixed."
},
{
"id": "c3",
"note": "Walking a neighbour's dog lifts you in 1 hour a week and keeps things flexible."
},
{
"id": "c4",
"note": "Treasurer for the allotment society drains you across 2 hours a week, though it's flexible."
}
],
"new_fish": "Coaching the under-10s football team would take 4 hours a week, and you guess it would leave you even, taking your week to 10.5 hours, 105% of your hours.",
"verdict": "no_room",
"rehome": "c4",
"rehome_note": "Freeing up treasurer for the allotment society's 2 hours a week would be enough on its own.",
"smaller_yes": "You could offer coaching the under-10s football team for 2 hours a week instead.",
"reply_line": "I can take on coaching the under-10s football team for 2 hours a week."
}
The street's bin rota - murky water
Input:
{
"task": "stock",
"willing_hours": "12",
"commitments": "c1: minutes secretary for the tenants' association - 2 h a week - drains you - flexible - 17% of your hours\nc2: refereeing league matches - 3 h a week - drains you - fixed - 25% of your hours\nc3: pottery class - 2 h a week - lifts you - flexible - 17% of your hours",
"new_ask": "organising the street's bin rota - 2 h a week - you guess it drains you - 17% of your hours",
"asked_by": "nobody named",
"facts": "willing: 12 h a week\nkept now: 7 h a week, 58% of your hours\nwith the new one: 9 h a week, 75% of your hours\ndraining hours with the new one: 7 of 9, 78%\nverdict: murky\nrehome: c1 (minutes secretary for the tenants' association, 2 h a week) - frees 2 h - enough: no\nsmaller_yes_hours: none"
}
Reply:
{
"task": "stock",
"headline": "Organising the street's bin rota would fit into your week, but too much of it would drain you.",
"fish": [
{
"id": "c1",
"note": "Minutes secretary for the tenants' association drains you over 2 hours a week, yet it's flexible."
},
{
"id": "c2",
"note": "Refereeing league matches drains you for 3 hours a week and stays fixed."
},
{
"id": "c3",
"note": "Pottery class lifts you for 2 hours a week and remains flexible."
}
],
"new_fish": "Organising the street's bin rota would take 2 hours a week, and you guess it would drain you, bringing your week to 9 hours, 75% of your hours.",
"verdict": "murky",
"rehome": "c1",
"rehome_note": "Freeing up minutes secretary for the tenants' association's 2 hours a week wouldn't be enough on its own.",
"smaller_yes": "",
"reply_line": "I can't take on organising the street's bin rota as it stands, but could a less draining part of it work instead?"
}
Library reading helper - a snug fit
Input:
{
"task": "stock",
"willing_hours": "9",
"commitments": "c1: running club - 2 h a week - lifts you - flexible - 22% of your hours\nc2: helping my dad with his garden - 2 h a week - leaves you even - fixed - 22% of your hours\nc3: hosting board-game night - 2 h a week - lifts you - flexible - 22% of your hours",
"new_ask": "volunteer reading helper at the library - 2 h a week - you guess it leaves you even - 22% of your hours",
"asked_by": "the librarian",
"facts": "willing: 9 h a week\nkept now: 6 h a week, 67% of your hours\nwith the new one: 8 h a week, 89% of your hours\ndraining hours with the new one: 0 of 8, 0%\nverdict: snug\nrehome: none\nsmaller_yes_hours: 1.5"
}
Reply:
{
"task": "stock",
"headline": "Volunteer reading helper at the library would just fit, leaving barely any space in your tank.",
"fish": [
{
"id": "c1",
"note": "Running club lifts you for 2 hours a week and stays flexible."
},
{
"id": "c2",
"note": "Helping your dad with his garden takes 2 hours a week, leaves you even, and is fixed."
},
{
"id": "c3",
"note": "Hosting board-game night lifts you for 2 hours a week and remains flexible."
}
],
"new_fish": "Volunteer reading helper at the library would take 2 hours a week, and you guess it would leave you even, bringing your week to 8 hours, 89% of your hours.",
"verdict": "snug",
"rehome": "",
"rehome_note": "",
"smaller_yes": "You could offer to be the volunteer reading helper at the library for 1.5 hours a week instead.",
"reply_line": "I can help as the volunteer reading helper at the library for 1.5 hours a week."
}
A beginners' running group - room in the tank
Input:
{
"task": "stock",
"willing_hours": "10",
"commitments": "c1: choir - 2 h a week - lifts you - flexible - 20% of your hours\nc2: language exchange chat - 1 h a week - lifts you - flexible - 10% of your hours\nc3: the residents' newsletter - 1.5 h a week - leaves you even - flexible - 15% of your hours",
"new_ask": "a beginners' running group - 1.5 h a week - you guess it lifts you - 15% of your hours",
"asked_by": "my friend Sam",
"facts": "willing: 10 h a week\nkept now: 4.5 h a week, 45% of your hours\nwith the new one: 6 h a week, 60% of your hours\ndraining hours with the new one: 0 of 6, 0%\nverdict: room\nrehome: none\nsmaller_yes_hours: none"
}
Reply:
{
"task": "stock",
"headline": "A beginners' running group would fit into your week with plenty of space to spare.",
"fish": [
{
"id": "c1",
"note": "Choir lifts you for 2 hours a week and stays flexible."
},
{
"id": "c2",
"note": "Your language exchange chat lifts you in 1 hour a week and remains flexible."
},
{
"id": "c3",
"note": "The residents' newsletter takes 1.5 hours a week, leaves you even, and stays flexible."
}
],
"new_fish": "A beginners' running group would take 1.5 hours a week, and you guess it would lift you, bringing your week to 6 hours, 60% of your hours.",
"verdict": "room",
"rehome": "",
"rehome_note": "",
"smaller_yes": "",
"reply_line": "I can take on a beginners' running group for 1.5 hours a week."
}
Build the input yourself
Load the engine in node (global.window = global; require("./tankkit.js")), then:
const K = TankKit;
const form = K.form({ willing: "10",
ask: { name: "coaching the under-10s football team", hours: "4", feel: "even", who: "the club secretary" },
items: [{ name: "book club", hours: "1.5", feel: "lifts" },
{ name: "treasurer for the allotment society", hours: "2", feel: "drains", fixed: false }] });
const built = K.buildInput(form); // built.facts holds the sums, the verdict, the rehome pick and the smaller yes
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.md(built, reply) // the whole tank as Markdown
Form keys: willing, ask (name, hours, feel, optional who) and up to eight items, each with name, hours, feel (lifts, even or drains) and an optional fixed. A missing hour or feeling stops buildInput with a note saying what to fill in.
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://room-in-the-tank.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://room-in-the-tank.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"]
// room-in-the-tank.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://room-in-the-tank.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://room-in-the-tank.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
}
// RoomInTheTank.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 RoomInTheTank {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://room-in-the-tank.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://room-in-the-tank.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://room-in-the-tank.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://room-in-the-tank.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":"room-in-the-tank"}' | 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": "room-in-the-tank"})["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: "room-in-the-tank" })).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": "room-in-the-tank"}, 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", "room-in-the-tank"), 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: "room-in-the-tank" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "room-in-the-tank"])["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 = "room-in-the-tank" }))!["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": "stock", "willing_hours": "10", "commitments": "...", "new_ask": "...", "asked_by": "the club secretary", "facts": "...", ...}
# - see the field table
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": "stock", "willing_hours": "10", "commitments": "...", "new_ask": "...", "asked_by": "the club secretary", "facts": "...", ...}
# - see the field table
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: "stock", willing_hours, commitments, new_ask, asked_by, facts} (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 room-in-the-tank:<task>:<inputHash>:a<attempt> (the one task is stock), 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="room-in-the-tank:stock:$(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 = "room-in-the-tank:" + 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 = `room-in-the-tank:${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 := "room-in-the-tank:" + 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 = "room-in-the-tank:" + 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 = "room-in-the-tank:#{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["verdict"]].inspect
$key = "room-in-the-tank:" . $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["verdict"] ?? ""), "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"room-in-the-tank:{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: room-in-the-tank:stock:$(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": "stock", "willing_hours": "10", "commitments": "...", "new_ask": "...", "asked_by": "the club secretary", "facts": "...", ...}
# - see the field table
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "room-in-the-tank:stock:" + 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": `room-in-the-tank:stock:${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", "room-in-the-tank:stock:"+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", "room-in-the-tank:stock:" + 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" => "room-in-the-tank:stock:#{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: room-in-the-tank:stock:" . 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", $"room-in-the-tank:stock:{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);