Drive Sink or Bob from your own code
The one metered task, dunk, is available over HTTP. It takes 2 to 8 everyday things and the water (plain or salty), plus the page's own density ranges for any it knows. For each item it returns the everyday hunch it tests, two basic facts about what it is made of, a density range in g/cm3 and a verdict (bobs, rides-low, sinks or depends), then one safe way to check at home and a tally - or a polite decline when the line-up holds a living creature or a hazardous item.
The division of labour first: the page knows rough densities for about 50 common things and applies a fixed rule; the model reasons each item from first principles and estimates the ones the page does not know; the page then re-checks every verdict against the model's own numbers, the table ranges, the item count, the tally and the word limits. Build items and known 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":"sink-or-bob"}), 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, water, count, items, known) 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: dunking a line-up 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: dunk (the one task is dunk); a missing or different one is still answered as dunk. status is declined when any item is a living creature or a person, a weapon, an explosive or firework, a fuel or household chemical, or a drug. Otherwise the water's density D is 1.00 for plain and 1.10 for salty, and the low-riding line L is 0.90 or 0.99. Each verdict is checked in this order against the item's own low and high, stopping at the first match: depends (low below D and high above D), sinks (low is D or more), rides-low (high is L or more), bobs (every other case).
Input (every field is a string)
| Field | Required | What it holds |
|---|---|---|
task | yes | Always dunk. |
water | yes | plain or salty. |
count | yes | How many items, 2 to 8, as digits. |
items | yes | Numbered lines, one per item, each name 2 to 40 characters - for example 1. apple then 2. orange, peeled. |
known | yes | Numbered lines n. low-high with two decimals for the items the page's table knows (for example 1. 0.73-0.85), or none. The model copies these ranges exactly. |
retry_note | no | Only on the page's one reformat retry, after an unusable reply. |
Reply (one JSON object)
{"task": "dunk", "status": "ok", "reason": "",
"items": [{"n": 1, "name": "apple", "verdict": "bobs", "low": 0.73, "high": 0.85, "hunch": "...", "axioms": ["...", "..."], "why": "...", "depends_on": ""}, ...],
"try_it": "...", "closing": "..."}
| Field | What it holds |
|---|---|
status, reason | ok or declined; for declined, one sentence of at most 25 words, items empty and try_it and closing empty. |
items | Exactly count entries in input order. Each: n, name (copied), verdict, low and high (numbers, g/cm3, two decimals), hunch (at most 12 words), axioms (exactly 2 facts, each at most 14 words), why (at most 30 words) and depends_on (at most 12 words, only for depends). |
try_it | One safe home check with a bowl of water, at most 30 words, naming any item to keep dry. |
closing | One sentence of at most 20 words giving the count of each verdict, as digits. |
The page then checks the reply (TubKit.check) and lists any disagreement under "The page's check".
Worked examples
These are the five free samples on the page: the exact input and a stand-in model's reply, unedited. Together they cover every outcome - the fruit bowl (all four verdicts), the desk drawer (an item to keep dry), strong salt water (salty), a pocketful (nothing in the page's table) and a bath-time line-up (declined).
The fruit bowl - all four verdicts
Input:
{
"task": "dunk",
"water": "plain",
"count": "6",
"items": "1. apple\n2. orange\n3. orange, peeled\n4. lime\n5. grapes\n6. banana",
"known": "1. 0.73-0.85\n2. 0.78-0.88\n3. 1.03-1.10\n4. 0.95-1.05\n5. 1.05-1.15\n6. 0.90-0.98"
}
Reply:
{
"task": "dunk",
"status": "ok",
"reason": "",
"items": [
{
"n": 1,
"name": "apple",
"verdict": "bobs",
"low": 0.73,
"high": 0.85,
"hunch": "You might assume a whole apple always floats.",
"axioms": [
"Apple flesh holds many tiny trapped air pockets throughout.",
"Its skin and flesh contain no added water or metal."
],
"why": "The trapped air keeps the apple lighter than plain water, so it floats well; the hunch that apples float holds true.",
"depends_on": ""
},
{
"n": 2,
"name": "orange",
"verdict": "bobs",
"low": 0.78,
"high": 0.88,
"hunch": "An orange is assumed to float just like an apple.",
"axioms": [
"Orange peel is thick and filled with tiny air pockets.",
"The peel's trapped air outweighs the denser juicy pulp inside."
],
"why": "The peel's air pockets keep the whole orange lighter than plain water, so it floats well; the floating hunch holds.",
"depends_on": ""
},
{
"n": 3,
"name": "orange, peeled",
"verdict": "sinks",
"low": 1.03,
"high": 1.1,
"hunch": "You might assume peeling an orange barely changes whether it floats.",
"axioms": [
"Peeled orange has no air-trapping rind left around it.",
"Its pulp is mostly dense juice and fiber."
],
"why": "Without the peel's trapped air, the pulp is denser than plain water, so it sinks; the hunch that peeling barely matters fails.",
"depends_on": ""
},
{
"n": 4,
"name": "lime",
"verdict": "depends",
"low": 0.95,
"high": 1.05,
"hunch": "You might assume limes always float like other citrus.",
"axioms": [
"Lime peel holds some trapped air like other citrus fruits.",
"Lime pulp and juice are nearly as dense as water."
],
"why": "The peel's air and the dense pulp roughly balance plain water's density, so it depends; the floating hunch could go either way.",
"depends_on": "More juice sinks it; more peel air floats it."
},
{
"n": 5,
"name": "grapes",
"verdict": "sinks",
"low": 1.05,
"high": 1.15,
"hunch": "You might assume grapes float like other round fruits.",
"axioms": [
"Grape skin is thin and traps very little air.",
"Grape flesh is mostly dense sugary juice."
],
"why": "With almost no trapped air, the sugary juice makes grapes denser than plain water, so they sink; the floating hunch fails.",
"depends_on": ""
},
{
"n": 6,
"name": "banana",
"verdict": "rides-low",
"low": 0.9,
"high": 0.98,
"hunch": "You might assume a banana floats high and dry.",
"axioms": [
"Banana flesh is dense and starchy with little air.",
"Its peel traps only a thin layer of air."
],
"why": "The starchy flesh nearly matches plain water's density, so the banana floats but rides low; the hunch of floating high fails.",
"depends_on": ""
}
],
"try_it": "Fill a bowl with plain water and gently drop in the apple, orange, lime and banana to compare how each floats.",
"closing": "2 bob, 1 ride low, 2 sink, 1 depend."
}
The desk drawer - an item to keep dry
Input:
{
"task": "dunk",
"water": "plain",
"count": "7",
"items": "1. wine cork\n2. pencil\n3. house key\n4. birthday candle\n5. crayon\n6. rubber band\n7. AA battery",
"known": "1. 0.12-0.24\n2. 0.50-0.70\n3. 8.40-8.70\n4. 0.88-0.93\n5. 1.02-1.10\n7. 2.70-3.10"
}
Reply:
{
"task": "dunk",
"status": "ok",
"reason": "",
"items": [
{
"n": 1,
"name": "wine cork",
"verdict": "bobs",
"low": 0.12,
"high": 0.24,
"hunch": "You might assume a cork always floats easily in water.",
"axioms": [
"Cork is a lightweight natural material full of air cells.",
"It holds no metal or dense filling inside it."
],
"why": "The many trapped air cells make cork far lighter than plain water, so it floats well; the floating hunch holds true.",
"depends_on": ""
},
{
"n": 2,
"name": "pencil",
"verdict": "bobs",
"low": 0.5,
"high": 0.7,
"hunch": "You might assume a wooden pencil floats like a stick.",
"axioms": [
"Pencil wood is light and holds small air pockets.",
"Its thin graphite core adds little extra weight."
],
"why": "The wood's air pockets keep the pencil lighter than plain water despite the core, so it floats; the stick hunch holds.",
"depends_on": ""
},
{
"n": 3,
"name": "house key",
"verdict": "sinks",
"low": 8.4,
"high": 8.7,
"hunch": "You might assume a small key is too light to sink.",
"axioms": [
"House keys are solid metal with no hollow space.",
"Metal is far denser than water with no trapped air."
],
"why": "As solid dense metal with no trapped air, the key is far denser than plain water, so it sinks; the small-and-light hunch fails.",
"depends_on": ""
},
{
"n": 4,
"name": "birthday candle",
"verdict": "rides-low",
"low": 0.88,
"high": 0.93,
"hunch": "You might assume a wax candle floats high like a cork.",
"axioms": [
"Candle wax is light but holds very little trapped air.",
"Its thin wick adds only a tiny bit of weight."
],
"why": "The wax is only slightly lighter than plain water, so the candle floats but rides low; the cork-like floating hunch fails.",
"depends_on": ""
},
{
"n": 5,
"name": "crayon",
"verdict": "sinks",
"low": 1.02,
"high": 1.1,
"hunch": "You might assume a wax crayon floats like a candle.",
"axioms": [
"Crayon wax is mixed with dense pigment and filler.",
"It holds no air pockets inside its solid stick."
],
"why": "The pigment-filled wax makes the crayon slightly denser than plain water with no air to help, so it sinks; the candle comparison fails.",
"depends_on": ""
},
{
"n": 6,
"name": "rubber band",
"verdict": "sinks",
"low": 1.1,
"high": 1.2,
"hunch": "You might assume a stretchy rubber band floats easily.",
"axioms": [
"Rubber bands are solid rubber with no trapped air.",
"Vulcanized rubber is denser than plain water."
],
"why": "As solid rubber with no air pocket, the band is denser than plain water, so it sinks; the stretchy-and-light hunch fails.",
"depends_on": ""
},
{
"n": 7,
"name": "AA battery",
"verdict": "sinks",
"low": 2.7,
"high": 3.1,
"hunch": "You might assume a small battery is too light to sink.",
"axioms": [
"An AA battery is a sealed metal and chemical cylinder.",
"It holds no trapped air inside its solid casing."
],
"why": "As a dense sealed metal cylinder with no trapped air, the battery is far denser than plain water, so it sinks; the small-and-light hunch fails.",
"depends_on": ""
}
],
"try_it": "Fill a bowl with plain water and drop in the cork, pencil and candle to watch them float, but keep the battery dry.",
"closing": "2 bob, 1 ride low, 4 sink."
}
Strong salt water - salty
Input:
{
"task": "dunk",
"water": "salty",
"count": "6",
"items": "1. fresh egg\n2. potato\n3. golf ball\n4. grapes\n5. ice cube\n6. old egg",
"known": "1. 1.06-1.10\n2. 1.07-1.10\n3. 1.13-1.15\n4. 1.05-1.15\n5. 0.91-0.92\n6. 0.92-0.99"
}
Reply:
{
"task": "dunk",
"status": "ok",
"reason": "",
"items": [
{
"n": 1,
"name": "fresh egg",
"verdict": "rides-low",
"low": 1.06,
"high": 1.1,
"hunch": "You might assume a fresh egg always sinks in water.",
"axioms": [
"A fresh egg has only a tiny air pocket inside.",
"Its shell and contents are mostly dense liquid and mineral."
],
"why": "With only a small air pocket, the egg is just a bit lighter than salty water, so it floats but rides low; the always-sinks hunch fails.",
"depends_on": ""
},
{
"n": 2,
"name": "potato",
"verdict": "rides-low",
"low": 1.07,
"high": 1.1,
"hunch": "You might assume a solid potato always sinks right away.",
"axioms": [
"A potato is dense starchy flesh with very little air.",
"It holds no hollow space or trapped air pockets."
],
"why": "The starchy flesh is only slightly lighter than salty water, so the potato floats but rides low; the always-sinks hunch mostly fails.",
"depends_on": ""
},
{
"n": 3,
"name": "golf ball",
"verdict": "sinks",
"low": 1.13,
"high": 1.15,
"hunch": "You might assume a golf ball is light enough to float.",
"axioms": [
"A golf ball has a dense rubber or resin core.",
"Its core holds no significant trapped air pocket."
],
"why": "The dense solid core makes the golf ball heavier than salty water, so it sinks; the light-and-floaty hunch fails.",
"depends_on": ""
},
{
"n": 4,
"name": "grapes",
"verdict": "depends",
"low": 1.05,
"high": 1.15,
"hunch": "You might assume grapes always behave the same in water.",
"axioms": [
"Grape skin is thin and traps very little air.",
"Grape flesh is dense sugary juice close to water."
],
"why": "The sugary juice sits so close to salty water's density that grapes could float or sink; the always-the-same hunch is unresolved.",
"depends_on": "More juice sinks it; more trapped air floats it."
},
{
"n": 5,
"name": "ice cube",
"verdict": "bobs",
"low": 0.91,
"high": 0.92,
"hunch": "You might assume ice always sinks like solid water would.",
"axioms": [
"Ice is frozen water with a slightly expanded crystal structure.",
"That expansion makes it lighter than the same liquid water."
],
"why": "Because ice is less dense than liquid, it floats well above salty water's density; the sinks-like-solid hunch fails.",
"depends_on": ""
},
{
"n": 6,
"name": "old egg",
"verdict": "rides-low",
"low": 0.92,
"high": 0.99,
"hunch": "You might assume an egg's age does not change whether it sinks.",
"axioms": [
"An aged egg holds a bigger air pocket than when laid.",
"Its shell and insides are otherwise dense liquid and mineral."
],
"why": "The larger air pocket now makes the egg only slightly lighter than salty water, so it floats but rides low; the age-does-not-matter hunch fails.",
"depends_on": ""
}
],
"try_it": "Mix salt into a bowl of water and lower in the fresh egg, potato, grapes, ice cube and old egg to compare.",
"closing": "1 bob, 3 ride low, 1 sink, 1 depend."
}
A pocketful - nothing in the page's table
Input:
{
"task": "dunk",
"water": "plain",
"count": "5",
"items": "1. plastic bottle cap\n2. paper clip\n3. spoon\n4. lip balm tube\n5. hair tie",
"known": "none"
}
Reply:
{
"task": "dunk",
"status": "ok",
"reason": "",
"items": [
{
"n": 1,
"name": "plastic bottle cap",
"verdict": "rides-low",
"low": 0.9,
"high": 0.95,
"hunch": "You might assume a small plastic cap floats high and dry.",
"axioms": [
"Plastic bottle caps are solid with no trapped air inside.",
"Common cap plastics sit just under water's own density."
],
"why": "With no trapped air, the solid plastic is only slightly lighter than plain water, so it floats but rides low; the floats-high hunch fails.",
"depends_on": ""
},
{
"n": 2,
"name": "paper clip",
"verdict": "sinks",
"low": 7.8,
"high": 8.0,
"hunch": "You might assume a tiny paper clip is too light to sink.",
"axioms": [
"A paper clip is bent from solid steel wire.",
"Steel wire holds no trapped air inside it."
],
"why": "As solid steel with no trapped air, the clip is far denser than plain water, so it sinks; the tiny-and-light hunch fails.",
"depends_on": ""
},
{
"n": 3,
"name": "spoon",
"verdict": "depends",
"low": 0.9,
"high": 7.9,
"hunch": "You might assume any spoon behaves the same in water.",
"axioms": [
"A metal spoon is solid metal with no trapped air.",
"A plastic spoon is solid plastic lighter than water."
],
"why": "Whether it sinks or floats turns on its material, since metal is far denser than plain water and plastic is lighter; the one-spoon hunch is unresolved.",
"depends_on": "Metal spoon sinks it; plastic spoon floats it."
},
{
"n": 4,
"name": "lip balm tube",
"verdict": "rides-low",
"low": 0.85,
"high": 0.95,
"hunch": "You might assume a small tube always floats out of the way.",
"axioms": [
"A lip balm tube holds mostly wax and light plastic.",
"Its casing traps little to no extra air."
],
"why": "The wax and plastic together sit just below plain water's density, so the tube floats but rides low; the floats-easily hunch mostly fails.",
"depends_on": ""
},
{
"n": 5,
"name": "hair tie",
"verdict": "sinks",
"low": 1.05,
"high": 1.15,
"hunch": "You might assume a stretchy hair tie is light enough to float.",
"axioms": [
"A hair tie is a solid loop of elastic material.",
"Its stretchy core traps no meaningful air pocket."
],
"why": "As solid elastic with no trapped air, the hair tie is a bit denser than plain water, so it sinks; the stretchy-and-light hunch fails.",
"depends_on": ""
}
],
"try_it": "Fill a bowl with plain water and drop in the bottle cap, spoon and lip balm tube to compare how each floats.",
"closing": "2 ride low, 2 sink, 1 depend."
}
Bath-time line-up - declined
Input:
{
"task": "dunk",
"water": "plain",
"count": "3",
"items": "1. rubber duck\n2. toy sailboat\n3. goldfish",
"known": "1. 0.10-0.30"
}
Reply:
{
"task": "dunk",
"status": "declined",
"reason": "This page only dunks everyday objects; living things and hazardous items never go in the tub.",
"items": [],
"try_it": "",
"closing": ""
}
Build the input yourself
Load the engine in node (global.window = global; require("./tub.js"); require("./tub-svg.js")), then:
const K = TubKit, D = TubDraw;
const built = K.buildInput({ water: "plain", items: [{ name: "apple", guess: "bob" }, { name: "orange, peeled", guess: "bob" }, { name: "hair tie", guess: "" }] });
built.input.known; // "1. 0.73-0.85\n2. 1.03-1.10" - the page's table
built.parsed.items.map(x => x.page); // ["bobs", "sinks", ""] - the page's own rule
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.score(built.parsed, reply) // your calls: { called, right, open }
// D.svg({ water: "plain", items: reply.items }) // the tub drawing
// K.md(built, reply) // the dunked line-up 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://sink-or-bob.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://sink-or-bob.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"]
// sink-or-bob.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://sink-or-bob.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://sink-or-bob.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
}
// SinkOrBob.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 SinkOrBob {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://sink-or-bob.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://sink-or-bob.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://sink-or-bob.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://sink-or-bob.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":"sink-or-bob"}' | 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": "sink-or-bob"})["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: "sink-or-bob" })).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": "sink-or-bob"}, 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", "sink-or-bob"), 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: "sink-or-bob" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "sink-or-bob"])["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 = "sink-or-bob" }))!["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": "dunk", "water": "plain", "count": "6", "items": "1. apple
2. ..."} - 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": "dunk", "water": "plain", "count": "6", "items": "1. apple
2. ..."} - 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: "dunk", water, count, items, known} (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 sink-or-bob:<task>:<inputHash>:a<attempt> (the one task is dunk), 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="sink-or-bob:dunk:$(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 = "sink-or-bob:" + 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 = `sink-or-bob:${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 := "sink-or-bob:" + 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 = "sink-or-bob:" + 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 = "sink-or-bob:#{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 = "sink-or-bob:" . $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 = $"sink-or-bob:{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: sink-or-bob:dunk:$(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": "dunk", "water": "plain", "count": "6", "items": "1. apple
2. ..."} - 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": "sink-or-bob:dunk:" + 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": `sink-or-bob:dunk:${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", "sink-or-bob:dunk:"+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", "sink-or-bob:dunk:" + 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" => "sink-or-bob:dunk:#{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: sink-or-bob:dunk:" . 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", $"sink-or-bob:dunk:{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);