Drive On the Bonfire from your own code
The one metered task, flip, is available over HTTP. It takes 2 to 6 small rules you live by and, for each, what you fear would happen if you broke it, plus the no-exceptions words the page found. For each rule it returns the rule's exact opposite (the flip), what that flip would break using only your stated fear, two scores from 0 to 3 and a verdict (burn, keep or trim, with a smaller version when trimmed), then one small first experiment and a tally - or a polite decline when a rule is about health, safety, the law, money owed, food or someone in your care.
The division of labour first: the page reads each rule's wording (no-exceptions words, how tightly it is worded, whether a fear is named) and applies a fixed sorting rule; the model flips each rule and scores it from your fear and the rule's own words; the page then re-checks every verdict against the model's own scores, the rule count, the smaller versions, the tally and the word limits. Build rules, fears and absolutes the same way (see "Build the input yourself") so the lines match.
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":"on-the-bonfire"}), 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, count, rules, fears, absolutes) 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: flip-testing a pile 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, the status and the verdicts
Every run sends task: flip (the one task is flip); a missing or different one is still answered as flip. status is declined when any rule or fear is about medicine or health treatment, safety, the law, money owed, food, eating or body weight, or the care of a child, a pet or another person. Otherwise each rule gets two whole-number scores: breaks (0 to 3, judged only from its fear - none stated or only about looks, feelings or others' opinions is 0) and costs (0 to 3, from the rule's own words, plus 1 when it allows no exceptions). The verdict is checked in this order, stopping at the first match: burn (breaks is 0), keep (breaks is at least costs), trim (every other case).
Input (every field is a string)
| Field | Required | What it holds |
|---|---|---|
task | yes | Always flip. |
count | yes | How many rules, 2 to 6, as digits. |
rules | yes | Numbered lines, one per rule, each at least three words and at most 90 characters - for example 1. Always make the bed before breakfast. |
fears | yes | Numbered lines, one per rule, giving what you fear if you broke it, or none stated - for example 1. none stated. |
absolutes | yes | Numbered lines n. word, word with the no-exceptions words the page found in a rule (always, never, every, only and the like), or none. A trimmed rule's smaller version must drop every one of them. |
retry_note | no | Only on the page's one reformat retry, after an unusable reply. |
Reply (one JSON object)
{"task": "flip", "status": "ok", "reason": "",
"rules": [{"n": 1, "rule": "Always make the bed before breakfast", "flip": "...", "what_breaks": "...", "breaks": 0, "costs": 2, "verdict": "burn", "smaller": "", "why": "..."}, ...],
"first_flip": "...", "closing": "..."}
| Field | What it holds |
|---|---|
status, reason | ok or declined; for declined, one sentence of at most 25 words, rules empty and first_flip and closing empty. |
rules | Exactly count entries in input order. Each: n, rule (copied), flip (at most 14 words), what_breaks (at most 25 words), breaks and costs (whole numbers 0 to 3), verdict, smaller (at most 16 words, only for trim) and why (at most 28 words). |
first_flip | One small experiment, at most 30 words: break the first burned rule once, or try the first trimmed rule's smaller version; empty when every rule is kept. |
closing | One sentence of at most 20 words giving the count of each verdict, as digits. |
The page then checks the reply (FireKit.check) and lists any disagreement under "The page's check".
Worked examples
These are the four free samples on the page: the exact input and a stand-in model's reply, unedited. Together they cover every outcome - household quirks, desk habits and weekend rules (between them burn, keep and trim) and a pile with a health rule (declined).
Household quirks
Input:
{
"task": "flip",
"count": "4",
"rules": "1. Always make the bed before breakfast\n2. Never leave dishes in the sink overnight\n3. Fold every towel in thirds before it goes in the cupboard\n4. Put my keys in the bowl by the door the moment I get home",
"fears": "1. none stated\n2. the pans get crusted and take ages to scrub\n3. the cupboard looks messy\n4. I lose them and miss the train",
"absolutes": "1. always\n2. never\n3. every"
}
Reply:
{
"task": "flip",
"status": "ok",
"reason": "",
"rules": [
{
"n": 1,
"rule": "Always make the bed before breakfast",
"flip": "Never make the bed before breakfast.",
"what_breaks": "This breaks nothing you named, since no fear was given for this rule.",
"breaks": 0,
"costs": 1,
"verdict": "burn",
"smaller": "",
"why": "Keeping this costs a small moment of effort each time, while breaking it breaks nothing you named."
},
{
"n": 2,
"rule": "Never leave dishes in the sink overnight",
"flip": "Always leave dishes in the sink overnight.",
"what_breaks": "The flip would leave pans crusted, though the scrubbing could be made up afterwards.",
"breaks": 1,
"costs": 2,
"verdict": "trim",
"smaller": "Rinse just the pans; other dishes can wait overnight.",
"why": "Keeping this costs a small nightly task with no exceptions, while the flip only brings extra scrubbing you can fix later."
},
{
"n": 3,
"rule": "Fold every towel in thirds before it goes in the cupboard",
"flip": "Stuff every towel into the cupboard unfolded.",
"what_breaks": "This breaks nothing you named, since the fear is only about how the cupboard looks.",
"breaks": 0,
"costs": 1,
"verdict": "burn",
"smaller": "",
"why": "Keeping this costs a small folding step with no exceptions, while breaking it breaks nothing you named beyond how things look."
},
{
"n": 4,
"rule": "Put my keys in the bowl by the door the moment I get home",
"flip": "Never put your keys in the bowl when you get home.",
"what_breaks": "The flip would mean losing your keys and missing the train.",
"breaks": 2,
"costs": 0,
"verdict": "keep",
"smaller": "",
"why": "Keeping this costs only a small moment of effort, while the flip risks losing your keys and missing the train."
}
],
"first_flip": "Try breaking 'make the bed before breakfast' once, on purpose, the next time it comes up, and notice what actually happens.",
"closing": "2 rules go on the bonfire, 1 stays on the woodpile and 1 is trimmed to kindling."
}
Desk habits
Input:
{
"task": "flip",
"count": "4",
"rules": "1. Reply to every email within an hour, even after work\n2. Never take a lunch break away from my desk\n3. Back up my laptop every Friday afternoon\n4. Only write notes in blue ink",
"fears": "1. people will think I am slow\n2. I fall behind on my list\n3. I lose months of work if it dies\n4. none stated",
"absolutes": "1. every\n2. never\n3. every\n4. only"
}
Reply:
{
"task": "flip",
"status": "ok",
"reason": "",
"rules": [
{
"n": 1,
"rule": "Reply to every email within an hour, even after work",
"flip": "Never reply to an email within an hour, even after work.",
"what_breaks": "This breaks nothing you named, since the fear is only about what other people might think.",
"breaks": 0,
"costs": 3,
"verdict": "burn",
"smaller": "",
"why": "Keeping this costs real time and effort with no exceptions, even outside work, while breaking it breaks nothing you named."
},
{
"n": 2,
"rule": "Never take a lunch break away from my desk",
"flip": "Always take your lunch break away from your desk.",
"what_breaks": "The flip would mean falling behind on your list, though you could catch up afterwards.",
"breaks": 1,
"costs": 3,
"verdict": "trim",
"smaller": "Stay at your desk for lunch most days.",
"why": "Keeping this costs a chunk of your day with no exceptions, while the flip only risks falling behind, which you could fix later."
},
{
"n": 3,
"rule": "Back up my laptop every Friday afternoon",
"flip": "Never back up your laptop, not even Fridays.",
"what_breaks": "The flip would mean losing months of work, which cannot be undone.",
"breaks": 3,
"costs": 2,
"verdict": "keep",
"smaller": "",
"why": "Keeping this costs a little time each Friday with no exceptions, while the flip could mean losing months of work for good."
},
{
"n": 4,
"rule": "Only write notes in blue ink",
"flip": "Write your notes in any ink but blue.",
"what_breaks": "This breaks nothing you named, since this rule has no stated fear.",
"breaks": 0,
"costs": 1,
"verdict": "burn",
"smaller": "",
"why": "Keeping this costs only a small choice of pen with no exceptions, while breaking it breaks nothing you named."
}
],
"first_flip": "Try letting one email wait past the hour, even after work, once on purpose, the next time it comes up, and notice what happens.",
"closing": "2 rules go on the bonfire, 1 is trimmed to kindling and 1 stays on the woodpile."
}
Weekend rules
Input:
{
"task": "flip",
"count": "4",
"rules": "1. Finish every book I start, however dull\n2. Never buy the cheaper brand of anything\n3. Arrive at least twenty minutes early to everything\n4. Water the houseplants every Sunday morning",
"fears": "1. I feel like a quitter\n2. cheap things break and I end up buying twice\n3. I miss the start of things\n4. they dry out and die",
"absolutes": "1. every\n2. never, anything\n3. everything\n4. every"
}
Reply:
{
"task": "flip",
"status": "ok",
"reason": "",
"rules": [
{
"n": 1,
"rule": "Finish every book I start, however dull",
"flip": "Never finish a book you start, however dull.",
"what_breaks": "This breaks nothing you named, since the fear is only about how you'd feel about yourself.",
"breaks": 0,
"costs": 3,
"verdict": "burn",
"smaller": "",
"why": "Keeping this costs real reading time with no exceptions, while breaking it breaks nothing you named beyond how you'd feel."
},
{
"n": 2,
"rule": "Never buy the cheaper brand of anything",
"flip": "Always buy the cheapest brand of everything.",
"what_breaks": "The flip would mean buying cheap things that break and cost you twice.",
"breaks": 2,
"costs": 3,
"verdict": "trim",
"smaller": "Buy the better brand most of the time.",
"why": "Keeping this costs more money on every purchase with no exceptions, while the flip only risks buying twice, which costs extra later."
},
{
"n": 3,
"rule": "Arrive at least twenty minutes early to everything",
"flip": "Arrive at least 20 minutes late to everything.",
"what_breaks": "The flip would mean missing the start of things you go to.",
"breaks": 2,
"costs": 3,
"verdict": "trim",
"smaller": "Arrive twenty minutes early most of the time.",
"why": "Keeping this costs real time before everything with no exceptions, while the flip only risks missing the start of things."
},
{
"n": 4,
"rule": "Water the houseplants every Sunday morning",
"flip": "Never water the houseplants, not even Sunday mornings.",
"what_breaks": "The flip would mean the houseplants dry out and die.",
"breaks": 2,
"costs": 2,
"verdict": "keep",
"smaller": "",
"why": "Keeping this costs a little time each Sunday with no exceptions, while the flip risks the houseplants drying out and dying."
}
],
"first_flip": "Try putting down a dull book before finishing it, once on purpose, the next time it comes up, and notice what happens.",
"closing": "1 rule goes on the bonfire, 2 are trimmed to kindling and 1 stays on the woodpile."
}
A pile with a health rule - declined
Input:
{
"task": "flip",
"count": "2",
"rules": "1. Never skip a dose of my blood pressure medicine\n2. Always iron my shirts on Sunday night",
"fears": "1. my blood pressure goes up\n2. none stated",
"absolutes": "1. never\n2. always"
}
Reply:
{
"task": "flip",
"status": "declined",
"reason": "This page never flips rules about medicine or health treatment, so please keep following this one or ask a professional.",
"rules": [],
"first_flip": "",
"closing": ""
}
Build the input yourself
Load the engine in node (global.window = global; require("./fire.js"); require("./fire-svg.js")), then:
const K = FireKit, D = FireDraw;
const built = K.buildInput({ rules: [{ text: "Always make the bed before breakfast", fear: "", guess: "keep" }, { text: "Put my keys in the bowl by the door", fear: "I lose them", guess: "keep" }] });
built.input.absolutes; // "1. always" - the no-exceptions words the page found
built.parsed.rules.map(x => x.lean); // ["burn", ""] - no fear named leans toward the fire
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.verdictOf(0, 2) // "burn" - the page's own sorting rule
// K.score(built.parsed, reply) // your hunches: { called, right, open }
// D.svg({ rules: reply.rules.map(r => ({ n: r.n, text: r.rule, verdict: r.verdict })) }) // the bonfire drawing
// K.md(built, reply) // the sorted pile as Markdown
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://on-the-bonfire.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://on-the-bonfire.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"]
// on-the-bonfire.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://on-the-bonfire.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://on-the-bonfire.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
}
// OnTheBonfire.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 OnTheBonfire {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://on-the-bonfire.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://on-the-bonfire.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://on-the-bonfire.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://on-the-bonfire.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":"on-the-bonfire"}' | 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": "on-the-bonfire"})["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: "on-the-bonfire" })).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": "on-the-bonfire"}, 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", "on-the-bonfire"), 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: "on-the-bonfire" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "on-the-bonfire"])["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 = "on-the-bonfire" }))!["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": "flip", "count": "2", "rules": "1. Always make the bed before breakfast\n2. Only write notes in blue ink", "fears": "1. none stated\n2. none stated", "absolutes": "1. always\n2. only"} - 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": "flip", "count": "2", "rules": "1. Always make the bed before breakfast\n2. Only write notes in blue ink", "fears": "1. none stated\n2. none stated", "absolutes": "1. always\n2. only"} - 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: "flip", count, rules, fears, absolutes} (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 on-the-bonfire:<task>:<inputHash>:a<attempt> (the one task is flip), 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="on-the-bonfire:flip:$(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 = "on-the-bonfire:" + 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("status"))
import { createHash } from "node:crypto";
const key = `on-the-bonfire:${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.status);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "on-the-bonfire:" + 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 = "on-the-bonfire:" + 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 = "on-the-bonfire:#{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["status"]].inspect
$key = "on-the-bonfire:" . $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["status"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"on-the-bonfire:{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: on-the-bonfire:flip:$(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": "flip", "count": "2", "rules": "1. Always make the bed before breakfast\n2. Only write notes in blue ink", "fears": "1. none stated\n2. none stated", "absolutes": "1. always\n2. only"} - 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": "on-the-bonfire:flip:" + 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": `on-the-bonfire:flip:${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", "on-the-bonfire:flip:"+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", "on-the-bonfire:flip:" + 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" => "on-the-bonfire:flip:#{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: on-the-bonfire:flip:" . 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", $"on-the-bonfire:flip:{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);