Drive Bones to Reef from your own code
The one metered task, sort-the-fall, is available over HTTP. It takes what you are winding down, when it ends, its leftovers (one per line, each with the page's stage sketch) and the people or groups who might take things. It returns a reading of the whole ending, one item per leftover (its stage, a taker, a first move and why the stage fits), a signpost note to leave where the thing used to be, and a last word.
The division of labour first: the page sorts every leftover it can by fixed word lists, and the reading follows a fixed order from the reply's own stages and takers. After a run the page checks that the reply kept every leftover in order and word for word, kept the page's stages, gave things only to takers from your list (or you, or unclaimed), reached the reading the order demands, stated the counts in the headline, never said anything was already handed over or agreed, and added no names, numbers, times or pronouns your input never had. A playful planning tool, not professional advice.
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":"bones-to-reef"}), 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, thing, leftovers, takers and the rest of the field table) 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 sorting is metered and needs 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 readings
Every run sends task: sort-the-fall (the one task is sort-the-fall); a missing or different one is still answered as sort-the-fall. The four stages: 1 scavengers (days to weeks), 2 enrichers (weeks to months), 3 slow feeders (months to years), 4 reef (years). The reading, first match wins: set-a-date when ends is undated; needs-takers when unclaimed leftovers are at least half of them; reef-ready when any leftover is at stage 4; otherwise picked-clean.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always sort-the-fall. |
thing | yes | What is winding down, at most 80 characters. |
ends | yes | soon, months or undated. |
leftover_count | yes | How many leftover lines follow (2 to 12), as a string. |
leftovers | yes | Numbered lines such as 1. two toolboxes [page stage: 1]; [page stage: none] when the page could not place it. |
takers | yes | Up to 8 lines, Name: note or just a name, or (none). |
retry_note | no | Only on the page's single reformat retry. |
The reply is one JSON object:
{"verdict": "reef-ready", "headline": "...", "items": [{"n": 1, "leftover": "two toolboxes", "stage": 1, "taker": "Marta", "move": "...", "why": "..."}, ...], "signpost": "...", "last_word": "..."}
| Field | What it holds |
|---|---|
verdict | The reading id, by the order above. |
headline | A plain reading with the leftover count and the unclaimed count in digits and the fullest stage (at most 26 words). |
items | One per leftover, same order: n, leftover (exact), stage 1-4, taker (a listed name, you or unclaimed), move (at most 18 words), why (16). |
signpost | The note to leave where the thing used to be (at most 45 words). |
last_word | One closing line that agrees with the reading (18). |
The page then checks the reply (ReefKit.check) and lists any disagreement under "The page's check".
Worked examples - every reading
These are the four free samples on the page: the exact input the page sends and the reply that replays for free.
The laundrette zine - no end date yet: set a date
Input:
{
"task": "sort-the-fall",
"thing": "the zine I photocopy for the laundrette noticeboard",
"ends": "undated",
"leftover_count": "5",
"leftovers": "1. a box of 60 back issues [page stage: 1]\n2. the long-arm stapler [page stage: none]\n3. a contacts list of 14 contributors [page stage: 2]\n4. my layout template [page stage: 3]\n5. the habit of writing every month [page stage: 4]",
"takers": "Rosa: runs the community centre library shelf\nDev: wants to start a comic"
}
Reply:
{
"verdict": "set-a-date",
"headline": "Winding down the zine for the laundrette noticeboard leaves 5 leftovers, 1 unclaimed, mostly at stage 1, scavengers.",
"items": [
{
"n": 1,
"leftover": "a box of 60 back issues",
"stage": 1,
"taker": "Rosa",
"move": "Ask Rosa if the community centre library shelf can take the back issues.",
"why": "A box of 60 back issues is something someone can take and use right away."
},
{
"n": 2,
"leftover": "the long-arm stapler",
"stage": 1,
"taker": "Dev",
"move": "Ask Dev if the comic could use the long-arm stapler.",
"why": "A long-arm stapler is something someone can carry off and use right away."
},
{
"n": 3,
"leftover": "a contacts list of 14 contributors",
"stage": 2,
"taker": "unclaimed",
"move": "Pin a note on the laundrette noticeboard asking who would like the contacts list.",
"why": "A contacts list of contributors is something others could keep using, not use up."
},
{
"n": 4,
"leftover": "my layout template",
"stage": 3,
"taker": "Dev",
"move": "Ask Dev if the new comic could use the layout template.",
"why": "A layout template is something that can keep helping with future issues slowly."
},
{
"n": 5,
"leftover": "the habit of writing every month",
"stage": 4,
"taker": "you",
"move": "Keep writing every month, with or without the zine.",
"why": "A habit of writing every month can keep going for years by itself."
}
],
"signpost": "This zine for the noticeboard is winding down, with no date yet. The back issues may go to Rosa's community centre library shelf, and the long-arm stapler and layout template might suit Dev's comic. Ask here if you're interested in the contacts list of contributors.",
"last_word": "Pick an end date for the zine."
}
The language exchange - ends soon, no takers listed: needs takers
Input:
{
"task": "sort-the-fall",
"thing": "my weekly language-exchange meetup at the cafe",
"ends": "soon",
"leftover_count": "5",
"leftovers": "1. laminated conversation cards [page stage: 1]\n2. the cafe's back-room booking on Wednesdays [page stage: 2]\n3. a group chat of 38 members [page stage: 2]\n4. a tin with £22 in it [page stage: 1]\n5. the friendships with Tomas and Ewa [page stage: 4]",
"takers": "(none)"
}
Reply:
{
"verdict": "needs-takers",
"headline": "This weekly language-exchange meetup's ending leaves 5 leftovers, 4 of them unclaimed, mostly at stage 1, Scavengers.",
"items": [
{
"n": 1,
"leftover": "laminated conversation cards",
"stage": 1,
"taker": "unclaimed",
"move": "Ask in the group chat whether anyone wants the conversation cards.",
"why": "The cards are something to take and start using straight away."
},
{
"n": 2,
"leftover": "the cafe's back-room booking on Wednesdays",
"stage": 2,
"taker": "unclaimed",
"move": "Ask the cafe whether someone else can keep the Wednesday booking.",
"why": "The back-room booking is a slot that could keep being used on Wednesdays."
},
{
"n": 3,
"leftover": "a group chat of 38 members",
"stage": 2,
"taker": "unclaimed",
"move": "Ask the group chat's members if someone wants to keep running it.",
"why": "The chat's 38 members are an audience that could keep talking on their own."
},
{
"n": 4,
"leftover": "a tin with £22 in it",
"stage": 1,
"taker": "unclaimed",
"move": "Ask the group chat's members openly how the £22 should be used.",
"why": "The tin's £22 can be spent or shared right away."
},
{
"n": 5,
"leftover": "the friendships with Tomas and Ewa",
"stage": 4,
"taker": "you",
"move": "Message Tomas and Ewa to keep in touch beyond the meetup.",
"why": "Friendships with Tomas and Ewa are what will keep lasting for years."
}
],
"signpost": "This weekly language-exchange meetup at the cafe is ending soon. The conversation cards, the Wednesday back-room booking, the group chat, and the tin with £22 still need a taker - if interested, please ask.",
"last_word": "Go find takers for the cards, the booking, the chat, and the tin."
}
The repair table - ends in a few months: reef ready
Input:
{
"task": "sort-the-fall",
"thing": "the repair table I run at the church hall",
"ends": "months",
"leftover_count": "5",
"leftovers": "1. two toolboxes [page stage: 1]\n2. a soldering iron [page stage: 1]\n3. the mailing list of 120 people [page stage: 2]\n4. a binder of repair notes [page stage: 3]\n5. the knack for fixing lamps [page stage: 4]",
"takers": "Marta: wants to run a repair night at the youth club\nthe church hall committee"
}
Reply:
{
"verdict": "reef-ready",
"headline": "5 leftovers remain from the repair table, 1 unclaimed, with stage 1, Scavengers, holding the most — ready for someone to use straight away.",
"items": [
{
"n": 1,
"leftover": "two toolboxes",
"stage": 1,
"taker": "Marta",
"move": "Ask Marta whether the toolboxes suit the youth club repair night before handing them over.",
"why": "Toolboxes are physical kit that gets used once someone takes them."
},
{
"n": 2,
"leftover": "a soldering iron",
"stage": 1,
"taker": "Marta",
"move": "Ask Marta whether the soldering iron suits the youth club repair night.",
"why": "A soldering iron is kit someone takes and uses straight away."
},
{
"n": 3,
"leftover": "the mailing list of 120 people",
"stage": 2,
"taker": "unclaimed",
"move": "Ask around the church hall for someone willing to keep the list going.",
"why": "A mailing list of 120 people is an audience a group could keep using."
},
{
"n": 4,
"leftover": "a binder of repair notes",
"stage": 3,
"taker": "Marta",
"move": "Ask Marta whether the binder of repair notes would help the youth club nights.",
"why": "A binder of repair notes is know-how that can keep helping for a long time."
},
{
"n": 5,
"leftover": "the knack for fixing lamps",
"stage": 4,
"taker": "you",
"move": "Keep fixing lamps for people who ask, so the knack stays alive.",
"why": "The knack for fixing lamps is a skill that stays with you."
}
],
"signpost": "The repair table that used to run here is ending. The two toolboxes, a soldering iron and a binder of repair notes may go to Marta for a youth club repair night. If you'd like to help keep the mailing list going, ask here.",
"last_word": "What's ending still leaves something lasting behind — the knack for fixing lamps stays with you."
}
The postcard stall - ends soon, every leftover has a taker: picked clean
Input:
{
"task": "sort-the-fall",
"thing": "my vintage postcard stall at the Saturday market",
"ends": "soon",
"leftover_count": "4",
"leftovers": "1. about 900 postcards in shoeboxes [page stage: 1]\n2. the gazebo [page stage: 1]\n3. the card reader [page stage: 1]\n4. my pitch at the market [page stage: 2]",
"takers": "Joe: sells old maps on the next pitch, asked about my gazebo and card reader\nLena: collects seaside postcards\nthe market manager: hands out the pitches"
}
Reply:
{
"verdict": "picked-clean",
"headline": "Your postcard stall's ending leaves 4 leftovers, 0 unclaimed, with stage 1 Scavengers holding the most.",
"items": [
{
"n": 1,
"leftover": "about 900 postcards in shoeboxes",
"stage": 1,
"taker": "Lena",
"move": "Ask Lena whether they want the postcards before you box them up.",
"why": "About 900 postcards in shoeboxes are things someone can take and use right away."
},
{
"n": 2,
"leftover": "the gazebo",
"stage": 1,
"taker": "Joe",
"move": "Ask Joe whether they want the gazebo before you take it down.",
"why": "The gazebo is a structure you can hand over all at once."
},
{
"n": 3,
"leftover": "the card reader",
"stage": 1,
"taker": "Joe",
"move": "Ask Joe whether they want the card reader before you pass it on.",
"why": "The card reader is equipment someone can take and start using right away."
},
{
"n": 4,
"leftover": "my pitch at the market",
"stage": 2,
"taker": "the market manager",
"move": "Ask the market manager about handing on your pitch at the market.",
"why": "Your pitch at the market is a spot someone else could step into and keep running."
}
],
"signpost": "This vintage postcard stall is ending soon. Lena may take the postcards, Joe may take the gazebo and card reader, and the market manager may take on the pitch. Ask before anything moves.",
"last_word": "Everything from your stall has somewhere to go; now it's just asking each one."
}
Build the input yourself
Load the engine in node (global.window = global; require("./reefkit.js")), then:
const K = ReefKit;
const built = K.buildInput(K.form({ thing: "the book swap shelf at work", ends: "soon", leftovers: "a crate of paperbacks\nthe sign-up sheet", takers: "Ali: runs the staff room" }));
built.facts.items // each leftover with the page's stage (0 = still sinking)
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 book swap") // the sorting 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://bones-to-reef.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://bones-to-reef.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"]
// bones-to-reef.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://bones-to-reef.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://bones-to-reef.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
}
// BonesToReef.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 BonesToReef {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://bones-to-reef.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://bones-to-reef.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://bones-to-reef.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://bones-to-reef.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":"bones-to-reef"}' | 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": "bones-to-reef"})["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: "bones-to-reef" })).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": "bones-to-reef"}, 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", "bones-to-reef"), 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: "bones-to-reef" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "bones-to-reef"])["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 = "bones-to-reef" }))!["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": "sort-the-fall", "thing": "the repair table I run at the church hall", "ends": "months", "leftover_count": "5", "leftovers": "1. two toolboxes [page stage: 1]\n2. ...", "takers": "Marta: wants to run a repair night at the youth club\n..."}
# - 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": "sort-the-fall", "thing": "the repair table I run at the church hall", "ends": "months", "leftover_count": "5", "leftovers": "1. two toolboxes [page stage: 1]\n2. ...", "takers": "Marta: wants to run a repair night at the youth club\n..."}
# - 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: "sort-the-fall", thing, ends, leftover_count, leftovers, takers} (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 bones-to-reef:<task>:<inputHash>:a<attempt> (the one task is sort-the-fall), 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="bones-to-reef:sort-the-fall:$(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 = "bones-to-reef:" + 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 = `bones-to-reef:${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 := "bones-to-reef:" + 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 = "bones-to-reef:" + 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 = "bones-to-reef:#{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 = "bones-to-reef:" . $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 = $"bones-to-reef:{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: bones-to-reef:sort-the-fall:$(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": "sort-the-fall", "thing": "the repair table I run at the church hall", "ends": "months", "leftover_count": "5", "leftovers": "1. two toolboxes [page stage: 1]\n2. ...", "takers": "Marta: wants to run a repair night at the youth club\n..."}
# - 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": "bones-to-reef:sort-the-fall:" + 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": `bones-to-reef:sort-the-fall:${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", "bones-to-reef:sort-the-fall:"+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", "bones-to-reef:sort-the-fall:" + 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" => "bones-to-reef:sort-the-fall:#{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: bones-to-reef:sort-the-fall:" . 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", $"bones-to-reef:sort-the-fall:{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);