Drive Sand Left from your own code
The one metered task, rescue, is available over HTTP. It takes a shelf of keepsakes - what each one is, where it lives, how many years that place has existed, how many copies exist elsewhere, and the signals - together with the page's own figures (the rule-of-thumb odds, a colour and an action per keepsake, the lifeboat order) and returns a verdict (rescue-now, copy-soon or steady) and a rescue plan: for every keepsake why it sits where it does, the first step and what to keep it as, plus 2 to 5 weekend steps and up to three watch-outs. A natural use is a family or club archive checklist that re-reads its shelf each season, or a community that is closing and wants to hand members a rescue order.
The division of labour first: the page owns the arithmetic; the model owns the judgement and the words; the page re-checks the reply. Reading the shelf, the Lindy odds, the signals, the colours, the actions and the lifeboat order come from sandkit.js, which runs in node as well as in the browser. The model never works out odds: SandKit.buildInput hands it every figure. SandKit.check holds each reply to them: the verdict by the rule, one plan per keepsake in lifeboat order with the page's action, and no number, capitalised name, he/she, field name or brand that the input does not hold.
Get a token on the token page (step 2 below also shows how to mint a guest token in code).
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"sand-left"}), 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, items, figures) is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: the rescue plan is metered and needs a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field and the verdict rule
Every run sends task: rescue (the only task; a missing or unknown one is treated as rescue). The verdict is decided by a fixed rule on the page's figures, checked in this order - the first that applies wins:
- rescue-now - a keepsake sits under a closing notice with no copy elsewhere (
closing_no_copyisyes), or at least half the shelf is red (red_halfisyes). - steady - nothing is red (
red_countis0). - copy-soon - everything else.
A keepsake's odds come from the Lindy rule of thumb: a place that has lasted A years has about A/(A+5) odds of lasting 5 more (an unknown age is taken as 3). A closing notice drops them to 0%, a lapsed plan halves them, an ageing device caps them at 50%. Risk is (1 - odds) times a copy factor (no copy 1, one 0.4, two or more 0.15), plus 0.1 with no export; red is 0.6 or more, amber 0.25 or more, green below. The action: red - rescue; not red with an app-only format - convert; amber - copy; green - leave. All of it is a rough rule of thumb, an estimate, not a forecast for any real place. The declared input schema carries scalars only, so the array- and object-valued fields (items, figures) travel JSON-encoded as strings (SandKit.toWire does this). Every value is a string.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | rescue. Required. |
shelf | string | What the person called the shelf, up to 80 characters. May be empty. |
items | string (JSON) | [{id, rank, what, where, host_years, age_assumed, copies, copies_assumed, signals, age_odds_5y, odds_5y, tier, action}] in lifeboat order (rank 1 first) - ids K1.. in the order typed, up to 20 keepsakes; host_years is unknown when not given; signals are labels; age_odds_5y is the rule of thumb from the age alone and odds_5y the odds after the signals; tier is red, amber or green. Required. |
figures | string (JSON) | {items, red_count, amber_count, green_count, closing_no_copy, red_half, unknown_age, horizon_years, first_in_line}, every value a string. Required. |
notes | string | Free text from the person, up to 400 characters. |
clip_note, retry_note | string | What the page cut, when it cut something; and only on a reformat retry, what was wrong. |
The reply
The job's output.output is a string holding one JSON object: task, verdict, title, summary, items (one {ref, action, why, first_step, keep_as} per keepsake, in lifeboat order), weekend (2-5 strings) and watch_outs (0-3 strings). Parse with SandKit.parseReply, then normalize(reply), then check(reply, built) where built = SandKit.buildInput(form); md(reply, built, form) writes the Markdown and csv(built, reply) the rescue list.
Worked examples
Replies produced against the app's prompt for the three sample shelves (made up; long fields shortened; the full replies ship in example-result.js). Together they cover every verdict and every action.
a family folder where almost everything has a copy - steady
Input:
{
"task": "rescue",
"shelf": "The family folder",
"items": "[{\"id\":\"K5\",\"rank\":\"1\",\"what\":\"Voice memos from the kids\",\"where\":\"old phone\",\"host_years\":\"10\",\"age_assumed\":\"no\",\"copies\":\"0\",\"copies_assumed\":\"no\",\"signals\":[\"ageing device\"],\"age_odds_5y\":\"67%\",\"odds_5y\":\"50%\",\"tier\":\"amber\",\"action\":\"copy\"},{\"id\":\"K3\",\"rank\":\"2\",\"what\":\"Wedding video\",\"where\":\"external hard drive\",\"host_years\":\"15\",\"age_assumed\":\"no\",\"copies\":\"1\",\"copies_assumed\":\"no\",\"signals\":[\"ageing device\"] ... (1247 characters)",
"figures": "{\"items\":\"5\",\"red_count\":\"0\",\"amber_count\":\"1\",\"green_count\":\"4\",\"closing_no_copy\":\"no\",\"red_half\":\"no\",\"unknown_age\":\"0\",\"horizon_years\":\"5\",\"first_in_line\":\"K5\"}",
"notes": "We already pay for the photo backup. The old phone still charges but the screen is cracked."
}
Reply:
{
"task": "rescue",
"verdict": "steady",
"title": "Steady - one old phone to copy",
"summary": "The family folder holds 5 keepsakes: 0 red, 1 amber and 4 green. Voice memos from the kids lead the list - an ageing device has cut its odds to 50%.",
"items": [
{
"ref": "K5",
"action": "copy",
"why": "The old phone has run 10 years - by the rule of thumb, about 67%. An ageing device brings that down to 50%, with no copies elsewhere.",
"first_step": "Look for an export or download option for the voice memos first.",
"keep_as": "audio files on a second drive"
},
{
"ref": "K3",
"action": "leave",
"why": "The external hard drive has run 15 years - by the rule of thumb, about 75%, though an ageing device brings that down to 50%, with 1 copy elsewhere.",
"first_step": "Confirm the copy of the wedding video still opens.",
"keep_as": "original video files on a second drive"
},
{
"ref": "K2",
"action": "convert",
"why": "The notes app has run 16 years - by the rule of thumb, about 76%. The odds don't change, but the app-only format adds risk, and 1 copy exists elsewhere.",
"first_step": "Copy the recipe notes out of the notes app into a plain text file.",
"keep_as": "plain text file"
},
{
"ref": "K4",
"action": "leave",
"why": "The email inbox has run 22 years - by the rule of thumb, about 81%, and 1 copy already exists elsewhere.",
"first_step": "Confirm the scanned tax letters still open from the email inbox.",
"keep_as": "PDF copies on a second drive"
},
{
"ref": "K1",
"action": "leave",
"why": "The photo backup service has run 19 years - by the rule of thumb, about 79%, with 2 copies elsewhere and an export tool available.",
"first_step": "Confirm the copies of the family photos still open.",
"keep_as": "original image files on a second drive"
}
],
"weekend": [
"Copy the voice memos off the old phone onto a second drive.",
"Confirm the wedding video copy on the external hard drive still opens."
],
"watch_outs": [
"The old phone still charges, but its screen is cracked.",
"The old phone holding the voice memos is an ageing device.",
"The external hard drive holding the wedding video is also an ageing device."
]
}
a writer's shelf where one young site holds the only copy - copy-soon
Input:
{
"task": "rescue",
"shelf": "Everything I have written",
"items": "[{\"id\":\"K1\",\"rank\":\"1\",\"what\":\"Ten years of blog posts\",\"where\":\"blogging site\",\"host_years\":\"3\",\"age_assumed\":\"no\",\"copies\":\"0\",\"copies_assumed\":\"no\",\"signals\":[\"export tool\"],\"age_odds_5y\":\"38%\",\"odds_5y\":\"38%\",\"tier\":\"red\",\"action\":\"rescue\"},{\"id\":\"K4\",\"rank\":\"2\",\"what\":\"Newsletter archive\",\"where\":\"newsletter platform\",\"host_years\":\"6\",\"age_assumed\":\"no\",\"copies\":\"1\",\"copies_assumed\":\"no\",\"signals\":[\"export tool\" ... (1013 characters)",
"figures": "{\"items\":\"4\",\"red_count\":\"1\",\"amber_count\":\"2\",\"green_count\":\"1\",\"closing_no_copy\":\"no\",\"red_half\":\"no\",\"unknown_age\":\"0\",\"horizon_years\":\"5\",\"first_in_line\":\"K1\"}",
"notes": "The blog moved to this site when the old one closed. I mostly write on my phone."
}
Reply:
{
"task": "rescue",
"verdict": "copy-soon",
"title": "One keepsake is red - start with the blog posts",
"summary": "Of your four keepsakes, one is red, two are amber and one is green. Ten years of blog posts lead the lifeboat order, with about 38% odds over 5 years.",
"items": [
{
"ref": "K1",
"action": "rescue",
"why": "The blogging site has run 3 years - by the rule of thumb, about 38% - and you have no copies elsewhere.",
"first_step": "Use the export tool to save the blog posts to a file you control.",
"keep_as": "plain text or PDF files on a second drive"
},
{
"ref": "K4",
"action": "copy",
"why": "The newsletter platform has run 6 years - by the rule of thumb, about 55% - but a lapsed plan lowers that to about 27%.",
"first_step": "Use the export tool to save the newsletter archive to a file you control.",
"keep_as": "plain text or PDF files on a second drive"
},
{
"ref": "K2",
"action": "copy",
"why": "The notes app has run 14 years - by the rule of thumb, about 74% - and you have no copies elsewhere.",
"first_step": "Look for an export or download option first; if none, copy the text out.",
"keep_as": "plain text files on a second drive"
},
{
"ref": "K3",
"action": "convert",
"why": "The USB stick has run 20 years - by the rule of thumb, about 80% - but an ageing device caps that at about 50%, and the app-only format adds risk.",
"first_step": "Look for an export or download option first, then save the files in a plain format.",
"keep_as": "plain text or document files on a second drive"
}
],
"weekend": [
"Export the blog posts into a file you control.",
"Use the export tool to save the newsletter archive.",
"Look for an export option for the poems, then copy the text out."
],
"watch_outs": [
"The short stories live on an ageing device, which could fail before you copy them out.",
"Those short stories are also in an app-only format, so convert them into a plain one.",
"The newsletter archive's plan has lapsed, lowering its odds compared to the platform's age alone."
]
}
a fan's shelf on a community that posted a closing notice - rescue-now
Input:
{
"task": "rescue",
"shelf": "My fandom years",
"items": "[{\"id\":\"K1\",\"rank\":\"1\",\"what\":\"Fan art and comments, 2015-2026\",\"where\":\"small art-sharing community\",\"host_years\":\"12\",\"age_assumed\":\"no\",\"copies\":\"0\",\"copies_assumed\":\"no\",\"signals\":[\"closing notice\",\"export tool\"],\"age_odds_5y\":\"71%\",\"odds_5y\":\"0%\",\"tier\":\"red\",\"action\":\"rescue\"},{\"id\":\"K2\",\"rank\":\"2\",\"what\":\"Group roleplay threads\",\"where\":\"the same community's forums\",\"host_years\":\"12\",\"age_assumed\":\"no\",\"copies ... (1256 characters)",
"figures": "{\"items\":\"5\",\"red_count\":\"2\",\"amber_count\":\"2\",\"green_count\":\"1\",\"closing_no_copy\":\"yes\",\"red_half\":\"no\",\"unknown_age\":\"0\",\"horizon_years\":\"5\",\"first_in_line\":\"K1\"}",
"notes": "The community posted its closing notice this month. The roleplay threads were written with three friends."
}
Reply:
{
"task": "rescue",
"verdict": "rescue-now",
"title": "A closing community holds the only copies",
"summary": "Of 5 keepsakes, 2 are red, 2 are amber and 1 is green. Fan art and comments leads the list because of a closing notice, which drops its odds to 0%.",
"items": [
{
"ref": "K1",
"action": "rescue",
"why": "The community has run 12 years - by the rule of thumb, about 71%. A closing notice drops it to 0%, and no copies exist elsewhere.",
"first_step": "Use the export tool to save the art and comments before the community closes.",
"keep_as": "original image files and plain text, on a second drive."
},
{
"ref": "K2",
"action": "rescue",
"why": "The forums have run 12 years - by the rule of thumb, about 71%. A closing notice drops it to 0%, with no export, and no copies exist elsewhere.",
"first_step": "Save the pages or copy the text out yourself, since no export option exists.",
"keep_as": "plain text files on a second drive."
},
{
"ref": "K4",
"action": "copy",
"why": "The laptop has existed 4 years - by the rule of thumb, about 44%. It is an ageing device, which raises the risk, and no copies exist elsewhere.",
"first_step": "Look for an export or download option first, then copy the sketchbook scans off the ageing laptop.",
"keep_as": "original image files on a second drive."
},
{
"ref": "K5",
"action": "convert",
"why": "The chat app has run 9 years - by the rule of thumb, about 64%. The app-only format raises the risk, and no copies exist elsewhere.",
"first_step": "Look for an export or download option first, then save the voice notes as a plain audio file.",
"keep_as": "audio files on a second drive."
},
{
"ref": "K3",
"action": "leave",
"why": "The email has run 20 years - by the rule of thumb, about 80%. No signals apply, and a copy exists elsewhere.",
"first_step": "Confirm the existing copy still opens and reads correctly.",
"keep_as": "the existing copy, as plain text or PDF files."
}
],
"weekend": [
"Use the export tool to copy the art and comments out of the closing community.",
"Save the pages or copy the roleplay threads out of the forums by hand.",
"Look for an export option, then copy the sketchbook scans off the ageing laptop.",
"Play the voice notes and record them outside the app-only format."
],
"watch_outs": [
"The forums are closing and have no export option, so save the pages or copy text out directly.",
"The laptop is an ageing device, which puts the sketchbook scans at risk.",
"The voice notes are stuck in an app-only format until they are converted to a plain one."
]
}
Build the input yourself
Load the script in node (global.window = global; require("./sandkit.js")), then:
const K = SandKit;
const form = K.form({ shelf: "Club archive",
list: "Meeting minutes | shared drive | 9 | 1\nEvent photos | photo site | 2 | 0 | export\nMember chat | chat app | 6 | 0 | app-only" });
const r = K.read(form); // r.items: odds, colour, action; r.order: the lifeboat; r.fig.verdict
const built = K.buildInput(form);
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes // the page's re-check of the reply
// K.csv(built, reply) // the rescue list, one row per keepsake
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://sand-left.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://sand-left.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"]
// sand-left.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://sand-left.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://sand-left.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
}
// SandLeft.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 SandLeft {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://sand-left.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://sand-left.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://sand-left.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://sand-left.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":"sand-left"}' | 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": "sand-left"})["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: "sand-left" })).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": "sand-left"}, 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", "sand-left"), 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: "sand-left" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "sand-left"])["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 = "sand-left" }))!["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": "rescue", "shelf": "Everything I have written", "items": "[{\"id\":\"K1\",\"rank\":\"1\",\"what\":\"Ten years of blog posts\",\"where\":\"blogging site\",\"host_years\":\"3\",\"age_assumed\":\"no\",\"copies\":\"0\",\"copies_assumed\":\"no\",\"signals\":[\"export tool\"],\"age_odds_5y\":\"38%\",\"odds_5y\":\"38%\",\"tier\":\"red\",\"action\":\"rescue\"}, ...]", "figures": "{...}", "notes": "The blog moved to this site when the old one closed. I mostly write on my phone."}
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": "rescue", "shelf": "Everything I have written", "items": "[{\"id\":\"K1\",\"rank\":\"1\",\"what\":\"Ten years of blog posts\",\"where\":\"blogging site\",\"host_years\":\"3\",\"age_assumed\":\"no\",\"copies\":\"0\",\"copies_assumed\":\"no\",\"signals\":[\"export tool\"],\"age_odds_5y\":\"38%\",\"odds_5y\":\"38%\",\"tier\":\"red\",\"action\":\"rescue\"}, ...]", "figures": "{...}", "notes": "The blog moved to this site when the old one closed. I mostly write on my phone."}
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: "rescue", shelf, items, figures, notes, ... (see the field table)}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses sand-left:<task>:<inputHash>:a<attempt> (the one task is rescue), 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="sand-left:rescue:$(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 = "sand-left:" + 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 = `sand-left:${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 := "sand-left:" + 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 = "sand-left:" + 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 = "sand-left:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["title"]].inspect
$key = "sand-left:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["title"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"sand-left:{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: sand-left:rescue:$(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": "rescue", "shelf": "Everything I have written", "items": "[{\"id\":\"K1\",\"rank\":\"1\",\"what\":\"Ten years of blog posts\",\"where\":\"blogging site\",\"host_years\":\"3\",\"age_assumed\":\"no\",\"copies\":\"0\",\"copies_assumed\":\"no\",\"signals\":[\"export tool\"],\"age_odds_5y\":\"38%\",\"odds_5y\":\"38%\",\"tier\":\"red\",\"action\":\"rescue\"}, ...]", "figures": "{...}", "notes": "The blog moved to this site when the old one closed. I mostly write on my phone."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "sand-left:rescue:" + 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": `sand-left:rescue:${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", "sand-left:rescue:"+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", "sand-left:rescue:" + 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" => "sand-left:rescue:#{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: sand-left:rescue:" . 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", $"sand-left:rescue:{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);