Drive Open Crumb from your own code
The metered read is available over HTTP: send a sourdough recipe and how the last loaf came out, and get back a bake / adjust / rework verdict, a diagnosis of the loaf, weight changes and the method rewritten with the cue for each stage. A natural use is a baking journal or a recipe-box app: your code sends the recipe and the page's baker's math, and gets back a structured card.
The division of labour first: the page does the arithmetic; the model judges and writes; the page re-checks every weight the model gives. Reading the ingredient lines, the baker's percentages (the starter split into its flour and water), the flags, the rule-of-thumb bulk estimate and the schedule come from crumbkit.js; the run input and the reply check come from crumbreply.js. Both run in node as well as in the browser. check holds each reply to the recipe: every "from" weight must be the recipe's own, a missing or far-off salt the page flagged must be answered, a loaf diagnosis needs loaf notes, and no prose may name an internal field. Bulk times and cup or spoon conversions are estimates.
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":"open-crumb"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare: never wrap it as {"input": {…}}.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or task / recipe 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 read 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
Every run sends task: "card" - the one task. Verdicts: bake (sound as written; no weight changes), adjust (one to three weight changes or a real method change), rework (several problems at once; every weight that has to change is given). The declared input schema carries scalars only, so object-valued fields travel JSON-encoded as strings (CrumbKit.toWire does this). The schema requires task and recipe.
Input fields
| Field | Type | Notes |
|---|---|---|
task, today | string | card; YYYY-MM-DD. |
level | string | beginner, comfortable or experienced. |
recipe | string | The recipe as pasted; past 6,000 characters every ingredient line is kept and the middle of the method is cut with a marker. |
formula | string (JSON) | [{ingredient, role, grams, pct, converted}]; role flour / water / milk / levain / salt / yeast / other; pct of the dough flour. |
totals | string (JSON) | {dough_flour, total_flour, total_water, hydration, salt_pct, levain_pct, whole_pct, dough_weight, starter_hydration} - hydration and salt include the starter. |
starter_hydration, kitchen_c, proof | string | Percent; Celsius (may be empty); fridge or counter. |
bulk_estimate | string (JSON) | {low_h, high_h, rise, kitchen_c, is_estimate} or empty - a rule of thumb, not a measurement. |
page_flags, loaf_signs | string (JSON) | [{level, text}] with level fix / warn / info; [{sign, likely}] read from the loaf notes. |
page_verdict, last_loaf | string | bake, adjust or rework; the baker's own notes (may be empty). |
retry_note | string | Only on a reformat retry. |
The reply
The job's output.output is a string holding one JSON object: title, verdict, summary, diagnosis [{sign, cause, note}] (cause under_fermented / over_fermented / shaping / oven / formula / starter / handling / other), changes [{ingredient, from_g, to_g, why}], steps [{stage, what, time, cue}] (stage levain / mix / rest / bulk / shape / proof / bake / cool / other), watch and numbers (strings). Parse it with CrumbKit.parseReply, then normalize(reply), then check(reply, state), which returns {notes, after}: the page's notes (info, warn) shown under "Checked by the page", and the recipe with the changes applied, with its new baker's math.
Worked examples
Replies produced against the app's prompt for the sample recipes (made up; lists shortened; the full replies ship in example-result.js). Together they cover every verdict. The page replays each of them free from its sample buttons.
country loaf - bake
Input:
{
"task": "card",
"today": "2026-10-20",
"level": "comfortable",
"starter_hydration": "100",
"kitchen_c": "24",
"proof": "fridge",
"recipe": "Weekend country loaf\n\n450 g bread flour\n50 g whole wheat flour\n375 g water\n100 g active starter (100% hydratio ... (469 characters)",
"formula": "[{\"ingredient\":\"bread flour\",\"role\":\"flour\",\"grams\":450,\"pct\":90,\"converted\":false},{\"ingredient\":\"whole wheat flour\",\"role\":\"flour\",\"grams\":50,\"pct\":10,\"converted\":false},\"... 3 more\"]",
"totals": "{\"dough_flour\":500,\"total_flour\":550,\"total_water\":425,\"hydration\":77.3,\"salt_pct\":1.8,\"levain_pct\":20,\"whole_pct\":9.1,\"dough_weight\":985,\"starter_hydration\":100}",
"bulk_estimate": "{\"low_h\":4.5,\"high_h\":6,\"rise\":\"50-75%\",\"kitchen_c\":24,\"is_estimate\":true}",
"page_flags": "[]",
"loaf_signs": "[]",
"page_verdict": "bake",
"last_loaf": "Great oven spring, the ear opened well, crumb open and glossy, crust deep brown. Family ate half of it warm."
}
Reply:
{
"task": "card",
"title": "Country Batard: Formula Working Well",
"verdict": "bake",
"summary": "This country loaf is ready to bake exactly as written - your last loaf shows the formula and method already working well. A hydration of about 77% with a modest 9% whole wheat, 1.8% salt and a 20% starter share are all in a normal, dependable range for your level. The great oven spring, open glossy crumb and deep brown crust you got describe a well-fermented, well-shaped loaf and a hot enough bake, so there's nothing here to fix. Next time, keep doing what you did - same stretch-and-fold schedule, same bulk cues, same bake - and just watch the bulk a little closer since it's an estimate.",
"diagnosis": [
{
"sign": "great oven spring, open glossy crumb, deep brown crust",
"cause": "other",
"note": "This describes a well-timed bulk fermentation, firm enough shaping for a good ear, and a hot bake with enough covered steam time - nothing here points to a problem to fix."
}
],
"changes": [],
"steps": [
{
"stage": "mix",
"what": "Mix the bread flour, whole wheat flour and water into a shaggy dough and let it rest.",
"time": "45 min",
"cue": ""
},
{
"stage": "mix",
"what": "Add the starter and salt, squeezing them in until the dough is smooth with no streaks or grainy bits.",
"time": "",
"cue": ""
},
{
"stage": "bulk",
"what": "Do four sets of stretch and folds, spaced about 30 minutes apart, to build strength.",
"time": "4 folds, 30 min apart",
"cue": "dough feels tighter and holds its folded shape between sets"
},
"... 5 more"
],
"watch": [
"Trust the rise and bubbles over the clock for bulk - it's still just an estimate.",
"Keep the same squeeze-in mix and fold schedule since they worked well this time.",
"... 2 more"
],
"numbers": [
"Hydration 77.3% with the starter",
"Salt 1.8% of all flour",
"... 2 more"
]
}
half whole wheat, dense - adjust
Input:
{
"task": "card",
"today": "2026-10-20",
"level": "comfortable",
"starter_hydration": "100",
"kitchen_c": "20",
"proof": "counter",
"recipe": "Half whole wheat sourdough\n- 250g whole wheat flour\n- 250g bread flour\n- 340g water\n- 75g starter\n- 10g salt\n- ... (270 characters)",
"formula": "[{\"ingredient\":\"whole wheat flour\",\"role\":\"flour\",\"grams\":250,\"pct\":50,\"converted\":false},{\"ingredient\":\"bread flour\",\"role\":\"flour\",\"grams\":250,\"pct\":50,\"converted\":false},\"... 4 more\"]",
"totals": "{\"dough_flour\":500,\"total_flour\":537.5,\"total_water\":377.5,\"hydration\":70.2,\"salt_pct\":1.9,\"levain_pct\":15,\"whole_pct\":46.5,\"dough_weight\":940,\"starter_hydration\":100}",
"bulk_estimate": "{\"low_h\":8,\"high_h\":11,\"rise\":\"about 50%\",\"kitchen_c\":20,\"is_estimate\":true}",
"page_flags": "[{\"level\":\"warn\",\"text\":\"46.5% of the flour is whole grain at 70.2% hydration - whole grain drinks more water; the dough may bake dense.\"}]",
"loaf_signs": "[{\"sign\":\"dense or little rise\",\"likely\":\"under-fermented\"},{\"sign\":\"gummy crumb\",\"likely\":\"under-fermented or under-baked\"}]",
"page_verdict": "adjust",
"last_loaf": "Dense and tight crumb, a bit gummy near the bottom, it barely rose in the oven. The dough only grew a little d ... (132 characters)"
}
Reply:
{
"task": "card",
"title": "Give This Loaf A Longer Bulk",
"verdict": "adjust",
"summary": "This formula is close to sound - the main problem was time, not the recipe itself. Your bulk only ran 4 hours, while the page's own estimate for a 20 C kitchen and your starter share is about 8-11 hours, so the dough went into the oven under-fermented, which explains the dense, tight, gummy crumb and the weak oven rise. The page also flagged that 70% hydration is on the low side for a dough that's 46.5% whole grain, so a little more water has been added to ease that. Next time, let the bulk run by the dough's own signs - about 50% rise, a domed top and bubbles at the edges - rather than stopping at 4 hours.",
"diagnosis": [
{
"sign": "dense and tight crumb, barely rose in the oven",
"cause": "under_fermented",
"note": "The dough only grew a little during the 4 hour bulk, far short of the page's own 8-11 hour estimate for a 20 C kitchen and your starter share, so there wasn't enough gas or structure built before baking."
},
{
"sign": "gummy near the bottom",
"cause": "under_fermented",
"note": "An under-fermented crumb often reads as gummy near the base once it's cut; the page also flagged that 70% hydration is on the low side for a dough that's 46.5% whole grain, which can bake denser and add to that gummy feel."
}
],
"changes": [
{
"ingredient": "water",
"from_g": 340,
"to_g": 366,
"why": "More water brings the hydration to about 75%, matching the page's flag that this whole-grain share needs more water so the crumb doesn't bake as dense."
}
],
"steps": [
{
"stage": "mix",
"what": "Mix the flours, water and starter together until no dry flour remains, then work in the salt and honey; knead about 5 minutes until the dough is smooth and holds together.",
"time": "5 min",
"cue": ""
},
{
"stage": "bulk",
"what": "Bulk ferment at room temperature, folding the dough gently in the bowl two or three times over the first couple of hours to build strength.",
"time": "about 8-11 h (estimate)",
"cue": "Dough looks domed and has risen about 50%, with bubbles at the surface and sides, and jiggles like jelly when you nudge the bowl - go by this, not the clock; 4 hours was far short last time."
},
{
"stage": "shape",
"what": "Turn the dough out onto a lightly floured surface and shape it into a tight boule or batard.",
"time": "",
"cue": ""
},
"... 3 more"
],
"watch": [
"Let the bulk go by the dough's signs - about 50% rise, a domed top, bubbles at the edges - not the clock.",
"Use the poke test before baking to confirm the final proof, since timing can run slower than you'd expect.",
"... 2 more"
],
"numbers": [
"Hydration about 75% of all the flour, starter included (up from 70%, after adding water)",
"Salt 1.9% of all the flour",
"... 2 more"
]
}
first loaf, spread flat - rework
Input:
{
"task": "card",
"today": "2026-10-20",
"level": "beginner",
"starter_hydration": "100",
"kitchen_c": "17",
"proof": "fridge",
"recipe": "My first sourdough (found online, changed a bit)\n500 g all-purpose flour\n460 g water\n25 g starter\n17 g salt\n\nM ... (245 characters)",
"formula": "[{\"ingredient\":\"all-purpose flour\",\"role\":\"flour\",\"grams\":500,\"pct\":100,\"converted\":false},{\"ingredient\":\"water\",\"role\":\"water\",\"grams\":460,\"pct\":92,\"converted\":false},\"... 2 more\"]",
"totals": "{\"dough_flour\":500,\"total_flour\":512.5,\"total_water\":472.5,\"hydration\":92.2,\"salt_pct\":3.3,\"levain_pct\":5,\"whole_pct\":0,\"dough_weight\":1002,\"starter_hydration\":100}",
"bulk_estimate": "{\"low_h\":20,\"high_h\":24,\"rise\":\"50-75%\",\"kitchen_c\":17,\"is_estimate\":true}",
"page_flags": "[{\"level\":\"fix\",\"text\":\"Salt is 3.3% of the flour - far above the usual 1.8-2.2%; it will taste salty and slow the starter.\"},{\"level\":\"warn\",\"text\":\"Hydration is 92.2% - very wet; hard to shape even for practised hands.\"},\"... 2 more\"]",
"loaf_signs": "[{\"sign\":\"spread flat\",\"likely\":\"over-fermented or weak shaping\"},{\"sign\":\"tunnel under the crust\",\"likely\":\"under-fermented or loose shaping\"},\"... 3 more\"]",
"page_verdict": "rework",
"last_loaf": "It was sticky and impossible to shape, spread flat like a pancake in the oven, very sour and pale, with a big ... (137 characters)"
}
Reply:
{
"task": "card",
"title": "Too wet, too salty, too little starter",
"verdict": "rework",
"summary": "This recipe needs a rework, not a small tweak. The dough was far too wet at 92% hydration and far too salty at 3.3% of all the flour, and with only 5% starter in a cool kitchen the six-hour counter bulk was nowhere near long enough to finish. That combination explains your notes: a dough too slack to shape, a flat spread in the oven, a sour taste, a pale crust and a tunnel under the crust. For the next bake, cut the water to bring hydration to about 70%, roughly quadruple the starter to speed up fermentation, and bring the salt down to about 2.0% of all the flour, then judge bulk by how the dough looks rather than the clock.",
"diagnosis": [
{
"sign": "sticky and impossible to shape",
"cause": "formula",
"note": "At 92% hydration this dough carries far more water than all-purpose flour and a first attempt can comfortably handle, which is what made it slack and unworkable."
},
{
"sign": "spread flat like a pancake in the oven",
"cause": "shaping",
"note": "A dough this wet is hard to shape with any tension, so it had little structure left to hold its shape once it went into the oven."
},
"... 3 more"
],
"changes": [
{
"ingredient": "starter",
"from_g": 25,
"to_g": 100,
"why": "More starter, about 20% of the flour, ferments faster and more reliably in a cool kitchen."
},
{
"ingredient": "water",
"from_g": 460,
"to_g": 335,
"why": "Brings hydration down to about 70% of all the flour, a dough a beginner can actually shape."
},
{
"ingredient": "salt",
"from_g": 17,
"to_g": 11,
"why": "Brings salt to about 2.0% of all the flour, the usual range; too much salt also slows the starter."
}
],
"steps": [
{
"stage": "mix",
"what": "Mix the flour, water and starter until no dry flour remains, then mix in the salt until it disappears.",
"time": "",
"cue": ""
},
{
"stage": "bulk",
"what": "Let the dough rise at room temperature, folding it in the bowl every 30-45 minutes for the first two hours to build strength, then leave it undisturbed to finish.",
"time": "about 11-13 h (estimate)",
"cue": "well risen and domed on top, with bubbles at the surface and sides, and a jiggle when the bowl is nudged"
},
{
"stage": "shape",
"what": "Turn the dough out and shape it firmly into a tight round, building surface tension on the outside; the lower hydration makes this much easier to manage than before.",
"time": "",
"cue": ""
},
"... 3 more"
],
"watch": [
"Judge bulk by how the dough looks, not the clock - in a cool kitchen it can take 11-13 hours even with more starter.",
"The dough should feel noticeably less sticky at the new hydration, but still handle it gently when shaping.",
"... 2 more"
],
"numbers": [
"Hydration 70% after the change, down from 92%",
"Salt 2.0% of all the flour after the change, down from 3.3%",
"... 2 more"
]
}
Build the input yourself
Load both scripts in node (global.window = {}; require("./crumbkit.js"); require("./crumbreply.js")), then:
const K = CrumbKit;
const recipe = "450 g bread flour\n50 g whole wheat flour\n375 g water\n100 g starter\n10 g salt\n\nMix, rest, fold, shape, cold proof, bake.";
const st = K.state({ recipe, starter_h: "100", temp: "24", unit: "C", level: "comfortable", proof: "fridge", start: "09:00", makes: "1", loaves: "2", last_loaf: "" });
console.log(st.math.hydration, st.math.salt_pct, st.bulk, st.flags, st.verdict); // baker's math, bulk estimate, flags, bake|adjust|rework
const wire = K.toWire(K.buildInput(st, { today: "2026-10-20" }).input); // POST this to /estimate and /run
// later: const reply = K.normalize(K.parseReply(job.output.output));
// const { notes, after } = K.check(reply, st); // after.math = the baker's math with the changes applied
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://open-crumb.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://open-crumb.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"]
// open-crumb.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://open-crumb.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://open-crumb.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
}
// OpenCrumb.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 OpenCrumb {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://open-crumb.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://open-crumb.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://open-crumb.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://open-crumb.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":"open-crumb"}' | 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": "open-crumb"})["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: "open-crumb" })).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": "open-crumb"}, 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", "open-crumb"), 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: "open-crumb" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "open-crumb"])["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 = "open-crumb" }))!["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": "card", "today": "2026-10-20", "level": "comfortable", "starter_hydration": "100", "kitchen_c": "20", "proof": "counter", "recipe": "Half whole wheat sourdough\n- 250g whole wheat flour ...", "formula": "[...]", "totals": "{...}", "bulk_estimate": "{...}", "page_flags": "[...]", "loaf_signs": "[...]", "page_verdict": "adjust", "last_loaf": "Dense and tight crumb ..."}
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": "card", "today": "2026-10-20", "level": "comfortable", "starter_hydration": "100", "kitchen_c": "20", "proof": "counter", "recipe": "Half whole wheat sourdough\n- 250g whole wheat flour ...", "formula": "[...]", "totals": "{...}", "bulk_estimate": "{...}", "page_flags": "[...]", "loaf_signs": "[...]", "page_verdict": "adjust", "last_loaf": "Dense and tight crumb ..."}
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: "card", today, level, starter_hydration, kitchen_c, proof, recipe, formula, totals, bulk_estimate, page_flags, loaf_signs, page_verdict, last_loaf (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 task and the input: a retry with the same key never bills twice. The page uses open-crumb:card:<inputHash>:a<attempt>, 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="open-crumb:card:$(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 = "open-crumb:" + 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's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `open-crumb:${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 := "open-crumb:" + 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 = "open-crumb:" + 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 = "open-crumb:#{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 = "open-crumb:" . $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 = $"open-crumb:{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: open-crumb:card:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
payload = json.load(open("input.json")) # the same run input as input.json: {"task": "card", "today": "2026-10-20", "level": "comfortable", "starter_hydration": "100", "kitchen_c": "20", "proof": "counter", "recipe": "Half whole wheat sourdough\n- 250g whole wheat flour ...", "formula": "[...]", "totals": "{...}", "bulk_estimate": "{...}", "page_flags": "[...]", "loaf_signs": "[...]", "page_verdict": "adjust", "last_loaf": "Dense and tight crumb ..."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "open-crumb:card:" + hashlib.sha256(json.dumps(payload).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 payload = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(payload), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `open-crumb:card:${createHash("sha256").update(JSON.stringify(payload)).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", "open-crumb:card:"+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 payload = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(payload.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "open-crumb:card:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(payload)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
payload = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "open-crumb:card:#{Digest::SHA256.hexdigest(payload)[0, 16]}:a1")
req.body = payload
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
$payload = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: open-crumb:card:" . substr(hash("sha256", $payload), 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 payload = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"open-crumb:card:{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);