Drive Recipe Generator from your own code
Everything the web page does is available over HTTP: send a list of what is in a kitchen plus the constraints, get the same structured recipe back. The natural uses are a meal planner that reads a real inventory, a kitchen-display script that turns a stock count into tonight's dish, and a batch job that asks what a hundred different pantries could each make.
One warning worth reading before you build on it: a generated recipe has not been cooked in a test kitchen, and the allergens array is the model's declaration over its own ingredient list — not a label lookup. If you put this in front of other people, carry that caveat through to them, and do not present the output as a check against an allergy.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
Send your token as Authorization: Bearer … on every call. There is no app-slug header. The token is already bound to this app, and the only call that names the app does so in its body — POST /guest with {"slug": "recipe-generator"}. A slug header is accepted and ignored, which is exactly why it is worth saying: a sample that sends one looks correct and proves nothing.
Error codes
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits. Call /estimate first and top up. |
forbidden | 403 | The token is valid but not for this app, or a guest token tried a metered run. /estimate and /me work for guests; /run does not. |
not_found | 404 | Unknown job id, unknown collection, or the app slug does not exist. |
conflict | 409 | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
validation_error | 422 | The input object is missing a required field — have is the usual one — or a field is the wrong type. A body that is not valid JSON at all comes back as a 400. |
rate_limited | 429 | Too many requests. Back off and retry; do not tight-loop. |
internal | 5xx | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. Get a token
The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token.
A guest token can call /me and /estimate. Asking for a recipe is metered, so it needs a personal token from signing in. Note that POST /guest takes no auth and answers 201 with {token, guest_id, expires_at}.
# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
# https://recipe-generator.skillsafe.ai/tokens.html
# export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. The slug goes in the
# BODY - there is no X-App-Slug header on any endpoint.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"recipe-generator"}'
# 201 {"ok":true,"data":{"token":"aut_...","guest_id":"gst_...","expires_at":"..."}}
# Open https://recipe-generator.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered recipe.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest",
data=json.dumps({"slug": "recipe-generator"}).encode(),
method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://recipe-generator.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered recipe.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "recipe-generator" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://recipe-generator.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered recipe.
guestBody := []byte(`{"slug":"recipe-generator"}`)
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader(guestBody))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://recipe-generator.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered recipe.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"recipe-generator\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"aut_..."}}
# Open https://recipe-generator.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered recipe.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = { slug: "recipe-generator" }.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://recipe-generator.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered recipe.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "recipe-generator"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"];
// Open https://recipe-generator.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered recipe.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post,
"https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"recipe-generator\"}",
Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
2. A tiny client
One helper that adds the headers, unwraps data and raises on error. Every later step uses it.
# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Put this in your shell profile and the samples below
# read as "call estimate", "call run".
export SKILLSAFE_TOKEN="YOUR_TOKEN" # from /tokens.html
export SS_BASE="https://api.skillsafe.ai/v1/app-api"
call() {
# call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$SS_BASE/$1" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$SS_BASE/$1" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
fi
}
import json, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from /tokens.html
class SkillSafeError(RuntimeError):
def __init__(self, err):
self.code = err.get("code")
self.status = err.get("status")
super().__init__(f"{self.code}: {err.get('message')}")
def call(path, body=None, idempotency_key=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(f"{BASE}/{path}", data=data,
method="POST" if data else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
if idempotency_key:
req.add_header("Idempotency-Key", idempotency_key)
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
raise SkillSafeError(payload.get("error") or {})
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from /tokens.html
async function call(path, body, idempotencyKey) {
const headers = {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
};
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
const res = await fetch(`${BASE}/${path}`, {
method: body === undefined ? "GET" : "POST",
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await res.json();
if (!payload.ok) {
const e = payload.error || {};
throw Object.assign(new Error(`${e.code}: ${e.message}`), e);
}
return payload.data;
}
const base = "https://api.skillsafe.ai/v1/app-api"
const token = "YOUR_TOKEN" // from /tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
Status int `json:"status"`
} `json:"error"`
}
func call(path string, body any, idempotencyKey string) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN"; // from /tokens.html
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody, String idempotencyKey) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json");
if (idempotencyKey != null) b = b.header("Idempotency-Key", idempotencyKey);
b = jsonBody == null ? b.GET() : b.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) {
throw new RuntimeException("app-api " + res.statusCode() + ": " + res.body());
}
return res.body(); // {"ok":true,"data":{...}}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from /tokens.html
def call(path, body = nil, idempotency_key: nil)
uri = URI("#{BASE}/#{path}")
req = body.nil? ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idempotency_key if idempotency_key
req.body = JSON.generate(body) unless body.nil?
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload.dig('error', 'code')}: #{payload.dig('error', 'message')}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from /tokens.html
function call(string $path, ?array $body = null, ?string $idempotencyKey = null): array {
$headers = ["Authorization: Bearer " . TOKEN, "Content-Type: application/json"];
if ($idempotencyKey !== null) {
$headers[] = "Idempotency-Key: " . $idempotencyKey;
}
$ch = curl_init(BASE . "/" . $path);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
static readonly HttpClient Http = new();
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Token = "YOUR_TOKEN"; // from /tokens.html
static async Task<JsonElement> Call(string path, object body = null, string idempotencyKey = null) {
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post,
$"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (idempotencyKey is not null) req.Headers.Add("Idempotency-Key", idempotencyKey);
if (body is not null) {
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
}
var res = await Http.SendAsync(req);
var payload = JsonSerializer.Deserialize<JsonElement>(await res.Content.ReadAsStringAsync());
if (!payload.GetProperty("ok").GetBoolean()) {
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
3. Check the session and the balance
GET /me returns exactly three fields — subject_type, subject_id and credits. There is no email, no name and no user id, so the signed-in test is subject_type == "user" and nothing else. A guest reads subject_type == "guest".
call me
# {"ok":true,"data":{"subject_type":"user","subject_id":"usr_...","credits":48210}}
me = call("me")
print(me["subject_type"], me["credits"])
signed_in = me["subject_type"] == "user"
const me = await call("me");
console.log(me.subject_type, me.credits);
const signedIn = me.subject_type === "user";
raw, err := call("me", nil, "")
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int64 `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null, null));
// {"ok":true,"data":{"subject_type":"user","subject_id":"usr_...","credits":48210}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
signed_in = me["subject_type"] == "user"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
$signedIn = $me["subject_type"] === "user";
var me = await Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
var signedIn = me.GetProperty("subject_type").GetString() == "user";
4. Price the run — free
The input object is exactly what the app's own form submits:
| field | type | meaning |
|---|---|---|
have | string, required | The pantry, as a person would type it: one item per line, or all on one line separated by commas. Quantities are welcome and optional — 400g tin chopped tomatoes, 6 chicken thighs, half a lemon, rice. This is the recipe's inventory. Salt, pepper, water and a neutral cooking oil are assumed present and never appear on the shopping list, so you do not need to list them. |
must_use | string, optional | The thing that will go off. It must appear in ingredients and carry real weight in the dish rather than turning up as a garnish. |
avoid | string, optional | Allergies, intolerances and hard dislikes, comma separated; a leading “no” is stripped. Absolute: nothing named here appears in the ingredients, the substitutions or the shopping list. The app scans all three and reports a breach. |
diet | string | One of none, vegetarian, vegan, pescatarian, gluten-free, dairy-free, nut-free, low-carb, halal, kosher-style. Taken literally, including the hidden cases — fish sauce, anchovy, gelatine, rennet, stock cubes, alcohol. |
equipment | array of strings | What is actually available: hob, oven, microwave, air-fryer, grill, blender, food-processor, slow-cooker, pressure-cooker, kettle, one-pan, no-cook. The recipe uses only what is listed. one-pan means one pan and one board for the whole dish — no second pot to boil pasta in. |
minutes | number | The budget from start to plate, including preparation: 15, 30, 45, 90, or 0 for no limit. It is a promise, not a hint: the app compares total_minutes against it and reports an overrun. |
servings | number | 1 to 12. The quantities are written for exactly this number. You do not need a second run to change it — see step 8. |
skill | string | beginner, comfortable or confident. Beginner means no unexplained technique and a doneness cue on every step; confident means “deglaze” and “until it coats a spoon” are used as-is. |
meal | string | any, breakfast, lunch, dinner, side, snack, dessert. |
notes | string, optional | Free text: who is eating, what they are bored of, which pan is available, how much washing up is tolerable. This is the field that most changes the answer — a stated dish name here is honoured rather than substituted away. |
pantry_facts | object | {items: [{id,label}], flags: [{id,label}]} — see below. |
revise | object, optional | {of, goal, previous} — the second run. See step 8. |
retry_note | string, optional | Send only on a retry, when a previous reply failed to parse. The instruction is obeyed exactly. |
pantry_facts, honestly
In the browser, pantry_facts is computed for free before the run by a local quantity parser: items is what it read, one entry per recognised ingredient with the quantity and unit it parsed, and flags is the set of deterministic checks that fired. An API caller does not have to reproduce any of that. Sending {"items": [], "flags": []} is legitimate and the recipe still works; the model reads have either way.
What makes it worth sending is the reconciliation contract: every id you send in flags must come back exactly once in coverage_check. That turns a fact your own tooling already established into something the recipe is held to. An entry with addressed: false and a reason in note is a correct answer — the model deliberately setting a flag aside — and is a different thing from silence. A flag that never appears at all is a failed run, not a passing one. Ids you did not send should not appear either.
These are the flag ids the browser raises, and the ones worth raising by hand:
| id | fires when |
|---|---|
PANTRY-THIN | Fewer than four non-staple ingredients were recognised. |
NO-QTY | Some entries carry no quantity, so the recipe has to choose one. |
NO-PROTEIN | Nothing reads as a protein — meat, fish, egg, dairy, beans, lentils, tofu, nuts. |
NO-FAT | No cooking fat beyond the assumed neutral oil. |
NO-AROMATIC | No onion, garlic, ginger, chilli, leek or celery. |
NO-ACID | Nothing acidic — no lemon, lime, vinegar, tomato, wine or yoghurt. |
NO-CARB | No rice, pasta, bread, potato or grain. |
ALLERGEN-<GROUP> | One per group found in the list, upper-cased with spaces hyphenated: ALLERGEN-DAIRY, ALLERGEN-GLUTEN, ALLERGEN-TREE-NUT, and so on across the fourteen. |
DIET-CONFLICT | An item in the list is excluded by the chosen diet. It is set aside, not cooked with. |
AVOID-CONFLICT | An item in the list matches something in avoid. Avoid wins. |
MUST-USE-ABSENT | must_use names something the parser could not find in have. |
TIME-TIGHT | The budget is 20 minutes or less and the list contains something that needs an hour. |
EQUIP-NONE | No equipment was listed at all. |
EQUIP-NOCOOK | no-cook was sent alongside a heat source. |
SERVINGS-HIGH | More than eight servings, where one domestic pan stops browning properly. |
RAW-RISK | The list contains something whose handling matters — poultry, pork, mince, eggs, shellfish, rice. The recipe owes a temperature or a rule, not just a time. |
PERISHABLE | Something in the list will not keep long. |
/estimate creates no job and charges nothing. It returns the model binding — model, model_alias, markup_bps — and the reservation: hold_credits is what gets held, min_credits is the balance you must clear to start, and sponsor_enabled says whether the app is covering the run. The hold prices the full output cap, so the charged_credits you see after settlement is usually far lower. Budget against hold_credits, report against charged_credits.
INPUT='{
"have": "6 chicken thighs, bone in\n400g tin chopped tomatoes\n250g basmati rice\n1 onion\n3 cloves garlic\nhalf a lemon\n150g natural yoghurt\n50g butter",
"must_use": "the yoghurt, it is a day past the date",
"avoid": "",
"diet": "none",
"equipment": ["hob", "oven", "kettle"],
"minutes": 45,
"servings": 4,
"skill": "comfortable",
"meal": "dinner",
"notes": "Two adults and two children. One deep pan with a lid, and I would rather not wash up three pans.",
"pantry_facts": {
"items": [
{"id": "ITEM-CHICKEN-THIGHS", "label": "6 chicken thighs, bone in"},
{"id": "ITEM-BASMATI-RICE", "label": "250 g basmati rice"}
],
"flags": [
{"id": "ALLERGEN-DAIRY", "label": "dairy is present, from natural yoghurt, butter"},
{"id": "RAW-RISK", "label": "handling matters for chicken thighs, rice"},
{"id": "PERISHABLE", "label": "natural yoghurt will not keep long"}
]
}
}'
call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":2380,"min_credits":340,"sponsor_enabled":false}}
#
# estimate is FREE. It creates no job and charges nothing. hold_credits is what
# gets RESERVED; charged_credits after settlement is normally much lower.
INPUT = {
"have": "6 chicken thighs, bone in\n400g tin chopped tomatoes\n250g basmati rice\n1 onion\n3 cloves garlic\nhalf a lemon\n150g natural yoghurt\n50g butter",
"must_use": "the yoghurt, it is a day past the date",
"avoid": "",
"diet": "none",
"equipment": ["hob", "oven", "kettle"],
"minutes": 45,
"servings": 4,
"skill": "comfortable",
"meal": "dinner",
"notes": "Two adults and two children. One deep pan with a lid, and I would rather not wash up three pans.",
"pantry_facts": {
"items": [
{"id": "ITEM-CHICKEN-THIGHS", "label": "6 chicken thighs, bone in"},
{"id": "ITEM-BASMATI-RICE", "label": "250 g basmati rice"}
],
"flags": [
{"id": "ALLERGEN-DAIRY", "label": "dairy is present, from natural yoghurt, butter"},
{"id": "RAW-RISK", "label": "handling matters for chicken thighs, rice"},
{"id": "PERISHABLE", "label": "natural yoghurt will not keep long"}
]
}
}
est = call("estimate", INPUT)
print(est["model"], est["model_alias"], est["markup_bps"])
print(est["hold_credits"], est["min_credits"], est["sponsor_enabled"])
# estimate is free: no job is created and nothing is charged. The hold is a
# reservation against the full output cap, not the price of the run.
const INPUT = {
"have": "6 chicken thighs, bone in\n400g tin chopped tomatoes\n250g basmati rice\n1 onion\n3 cloves garlic\nhalf a lemon\n150g natural yoghurt\n50g butter",
"must_use": "the yoghurt, it is a day past the date",
"avoid": "",
"diet": "none",
"equipment": ["hob", "oven", "kettle"],
"minutes": 45,
"servings": 4,
"skill": "comfortable",
"meal": "dinner",
"notes": "Two adults and two children. One deep pan with a lid, and I would rather not wash up three pans.",
"pantry_facts": {
"items": [
{"id": "ITEM-CHICKEN-THIGHS", "label": "6 chicken thighs, bone in"},
{"id": "ITEM-BASMATI-RICE", "label": "250 g basmati rice"}
],
"flags": [
{"id": "ALLERGEN-DAIRY", "label": "dairy is present, from natural yoghurt, butter"},
{"id": "RAW-RISK", "label": "handling matters for chicken thighs, rice"},
{"id": "PERISHABLE", "label": "natural yoghurt will not keep long"}
]
}
};
const est = await call("estimate", INPUT);
console.log(est.model, est.model_alias, est.markup_bps);
console.log(est.hold_credits, est.min_credits, est.sponsor_enabled);
// estimate is free: no job is created and nothing is charged.
input := map[string]any{
"have": "6 chicken thighs, bone in\n400g tin chopped tomatoes\n250g basmati rice\n1 onion\n3 cloves garlic\nhalf a lemon\n150g natural yoghurt\n50g butter",
"must_use": "the yoghurt, it is a day past the date",
"avoid": "",
"diet": "none",
"equipment": []string{"hob", "oven", "kettle"},
"minutes": 45,
"servings": 4,
"skill": "comfortable",
"meal": "dinner",
"notes": "Two adults and two children. One deep pan with a lid.",
"pantry_facts": map[string]any{
"items": []map[string]string{
{"id": "ITEM-CHICKEN-THIGHS", "label": "6 chicken thighs, bone in"},
},
"flags": []map[string]string{
{"id": "ALLERGEN-DAIRY", "label": "dairy is present, from natural yoghurt, butter"},
{"id": "RAW-RISK", "label": "handling matters for chicken thighs, rice"},
{"id": "PERISHABLE", "label": "natural yoghurt will not keep long"},
},
},
}
raw, err := call("estimate", input, "")
if err != nil {
panic(err)
}
fmt.Println(string(raw)) // model, model_alias, markup_bps, hold_credits, min_credits
// Build the input with your JSON library of choice; the shape is in the table above.
String input = """
{
"have": "6 chicken thighs, bone in\n400g tin chopped tomatoes\n250g basmati rice\n1 onion\n3 cloves garlic\nhalf a lemon\n150g natural yoghurt\n50g butter",
"must_use": "the yoghurt, it is a day past the date",
"avoid": "",
"diet": "none",
"equipment": ["hob", "oven", "kettle"],
"minutes": 45,
"servings": 4,
"skill": "comfortable",
"meal": "dinner",
"notes": "Two adults and two children. One deep pan with a lid, and I would rather not wash up three pans.",
"pantry_facts": {
"items": [
{"id": "ITEM-CHICKEN-THIGHS", "label": "6 chicken thighs, bone in"},
{"id": "ITEM-BASMATI-RICE", "label": "250 g basmati rice"}
],
"flags": [
{"id": "ALLERGEN-DAIRY", "label": "dairy is present, from natural yoghurt, butter"},
{"id": "RAW-RISK", "label": "handling matters for chicken thighs, rice"},
{"id": "PERISHABLE", "label": "natural yoghurt will not keep long"}
]
}
}
""";
System.out.println(call("estimate", input, null));
// {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
// "markup_bps":1000,"hold_credits":2380,"min_credits":340,"sponsor_enabled":false}}
INPUT = {
have: "6 chicken thighs, bone in\n400g tin chopped tomatoes\n250g basmati rice\n1 onion\n" \
"3 cloves garlic\nhalf a lemon\n150g natural yoghurt\n50g butter",
must_use: "the yoghurt, it is a day past the date",
avoid: "",
diet: "none",
equipment: %w[hob oven kettle],
minutes: 45,
servings: 4,
skill: "comfortable",
meal: "dinner",
notes: "Two adults and two children. One deep pan with a lid.",
pantry_facts: {
items: [{ id: "ITEM-CHICKEN-THIGHS", label: "6 chicken thighs, bone in" }],
flags: [
{ id: "ALLERGEN-DAIRY", label: "dairy is present, from natural yoghurt, butter" },
{ id: "RAW-RISK", label: "handling matters for chicken thighs, rice" },
{ id: "PERISHABLE", label: "natural yoghurt will not keep long" }
]
}
}
est = call("estimate", INPUT)
puts "#{est['model']} #{est['model_alias']} #{est['markup_bps']}"
puts "#{est['hold_credits']} #{est['min_credits']}"
<?php
$input = [
"have" => "6 chicken thighs, bone in\n400g tin chopped tomatoes\n250g basmati rice\n"
. "1 onion\n3 cloves garlic\nhalf a lemon\n150g natural yoghurt\n50g butter",
"must_use" => "the yoghurt, it is a day past the date",
"avoid" => "",
"diet" => "none",
"equipment" => ["hob", "oven", "kettle"],
"minutes" => 45,
"servings" => 4,
"skill" => "comfortable",
"meal" => "dinner",
"notes" => "Two adults and two children. One deep pan with a lid.",
"pantry_facts" => [
"items" => [["id" => "ITEM-CHICKEN-THIGHS", "label" => "6 chicken thighs, bone in"]],
"flags" => [
["id" => "ALLERGEN-DAIRY", "label" => "dairy is present, from natural yoghurt, butter"],
["id" => "RAW-RISK", "label" => "handling matters for chicken thighs, rice"],
["id" => "PERISHABLE", "label" => "natural yoghurt will not keep long"],
],
],
];
$est = call("estimate", $input);
echo $est["model"], " ", $est["hold_credits"], PHP_EOL;
var input = new {
have = "6 chicken thighs, bone in\n400g tin chopped tomatoes\n250g basmati rice\n1 onion\n"
+ "3 cloves garlic\nhalf a lemon\n150g natural yoghurt\n50g butter",
must_use = "the yoghurt, it is a day past the date",
avoid = "",
diet = "none",
equipment = new[] { "hob", "oven", "kettle" },
minutes = 45,
servings = 4,
skill = "comfortable",
meal = "dinner",
notes = "Two adults and two children. One deep pan with a lid.",
pantry_facts = new {
items = new[] { new { id = "ITEM-CHICKEN-THIGHS", label = "6 chicken thighs, bone in" } },
flags = new[] {
new { id = "ALLERGEN-DAIRY", label = "dairy is present, from natural yoghurt, butter" },
new { id = "RAW-RISK", label = "handling matters for chicken thighs, rice" },
new { id = "PERISHABLE", label = "natural yoghurt will not keep long" },
},
},
};
var est = await Call("estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
5. Run it, then poll
POST /run takes the input object itself as the body. Do not wrap it in an input key: the wrapper is not rejected, it returns 200 with a plausible hold, and the model then never sees your fields at all. It answers {job_id}; poll GET /jobs/{job_id} to a terminal state.
Always send an Idempotency-Key derived from the input. A retried request with the same key returns the same job instead of billing twice; the same key with a different body is a 409. The app uses recipe-generator:<hash of the input>:a<attempt>.
KEY="recipe-generator:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "$SS_BASE/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
# poll to a terminal state
while :; do
OUT=$(call "jobs/$JOB")
STATUS=$(printf '%s' "$OUT" | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && { echo "$OUT"; exit 1; }
sleep 2
done
# the recipe JSON is a STRING inside the envelope, so unwrap twice
printf '%s' "$OUT" | python3 -c '
import json, sys
job = json.load(sys.stdin)["data"]
recipe = json.loads(job["output"]["output"])
print(recipe["recipe_name"], "|", recipe["fit"])
print("charged", job.get("charged_credits"))'
import hashlib, json, time
key = "recipe-generator:" + hashlib.sha256(
json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16] + ":a1"
job = call("run", INPUT, idempotency_key=key) # the input object IS the body
job_id = job["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error") or "run failed")
# The recipe is a JSON string inside the envelope: unwrap twice.
recipe = json.loads(job["output"]["output"])
print(recipe["recipe_name"], recipe["fit"], recipe["total_minutes"], "min")
print("charged", job.get("charged_credits"), "of a", job.get("hold_credits"), "hold")
import { createHash } from "node:crypto";
const key = "recipe-generator:" +
createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16) + ":a1";
let job = await call("run", INPUT, key); // the input object IS the body
const jobId = job.job_id;
while (true) {
job = await call(`jobs/${jobId}`);
if (job.status === "succeeded" || job.status === "failed") break;
await new Promise((r) => setTimeout(r, 2000));
}
if (job.status === "failed") throw new Error(job.error || "run failed");
// The recipe is a JSON string inside the envelope: unwrap twice.
const recipe = JSON.parse(job.output.output);
console.log(recipe.recipe_name, recipe.fit, recipe.total_minutes);
console.log("charged", job.charged_credits);
sum := sha256.Sum256([]byte(fmt.Sprint(input)))
key := "recipe-generator:" + hex.EncodeToString(sum[:])[:16] + ":a1"
raw, err := call("run", input, key) // the input object IS the body
if err != nil {
panic(err)
}
var started struct{ JobID string `json:"job_id"` }
_ = json.Unmarshal(raw, &started)
var job struct {
Status string `json:"status"`
ChargedCredits int64 `json:"charged_credits"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
for {
raw, err = call("jobs/"+started.JobID, nil, "")
if err != nil {
panic(err)
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" || job.Status == "failed" {
break
}
time.Sleep(2 * time.Second)
}
// The recipe is a JSON string inside the envelope: unwrap twice.
var recipe struct {
Name string `json:"recipe_name"`
Fit string `json:"fit"`
}
_ = json.Unmarshal([]byte(job.Output.Output), &recipe)
fmt.Println(recipe.Name, recipe.Fit, job.ChargedCredits)
var digest = MessageDigest.getInstance("SHA-256").digest(input.getBytes(UTF_8));
var key = "recipe-generator:" + HexFormat.of().formatHex(digest).substring(0, 16) + ":a1";
var started = call("run", input, key); // the input object IS the body
// {"ok":true,"data":{"job_id":"job_..."}}
var jobId = started.split("\"job_id\":\"")[1].split("\"")[0];
String job;
while (true) {
job = call("jobs/" + jobId, null, null);
if (job.contains("\"status\":\"succeeded\"") || job.contains("\"status\":\"failed\"")) break;
Thread.sleep(2000);
}
// data.output.output is a JSON STRING holding the recipe: parse it a second time.
System.out.println(job);
require "digest"
key = "recipe-generator:#{Digest::SHA256.hexdigest(JSON.generate(INPUT))[0, 16]}:a1"
job = call("run", INPUT, idempotency_key: key) # the input object IS the body
job_id = job["job_id"]
loop do
job = call("jobs/#{job_id}")
break if %w[succeeded failed].include?(job["status"])
sleep 2
end
raise(job["error"] || "run failed") if job["status"] == "failed"
# The recipe is a JSON string inside the envelope: unwrap twice.
recipe = JSON.parse(job.dig("output", "output"))
puts "#{recipe['recipe_name']} | #{recipe['fit']}"
puts "charged #{job['charged_credits']}"
<?php
$key = "recipe-generator:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = call("run", $input, $key); // the input object IS the body
$jobId = $job["job_id"];
while (true) {
$job = call("jobs/" . $jobId);
if ($job["status"] === "succeeded" || $job["status"] === "failed") {
break;
}
sleep(2);
}
if ($job["status"] === "failed") {
throw new RuntimeException($job["error"] ?? "run failed");
}
// The recipe is a JSON string inside the envelope: unwrap twice.
$recipe = json_decode($job["output"]["output"], true);
echo $recipe["recipe_name"], " | ", $recipe["fit"], PHP_EOL;
echo "charged ", $job["charged_credits"], PHP_EOL;
var json = JsonSerializer.Serialize(input);
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)))[..16].ToLower();
var key = $"recipe-generator:{hash}:a1";
var started = await Call("run", input, key); // the input object IS the body
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
while (true) {
job = await Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status is "succeeded" or "failed") break;
await Task.Delay(2000);
}
// The recipe is a JSON string inside the envelope: unwrap twice.
var recipeJson = job.GetProperty("output").GetProperty("output").GetString();
var recipe = JsonSerializer.Deserialize<JsonElement>(recipeJson);
Console.WriteLine(recipe.GetProperty("recipe_name").GetString());
Console.WriteLine(recipe.GetProperty("fit").GetString());
6. Or stream it
POST /run-stream is the same body and the same Idempotency-Key, answered as server-sent events. Each delta carries a chunk of the recipe JSON; the terminal event carries the job. A recipe takes long enough that a progress indication is worth having — the web page maps the arrival of "ingredients", "steps" and "coverage_check" in the delta stream onto named stages.
curl -sS -N -X POST "$SS_BASE/run-stream" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
-d "$INPUT"
# event: job data: {"job_id":"job_..."}
# event: delta data: {"text":"{\"recipe_name\":\"Chicken thighs"}
# event: delta data: {"text":" braised with tinned tomatoes"}
# ...
# event: done data: {"status":"succeeded","charged_credits":612,"truncated":false}
import json, urllib.request
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(),
method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
req.add_header("Accept", "text/event-stream")
raw = ""
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().rstrip("\n")
if not line.startswith("data:"):
continue
payload = json.loads(line[5:].strip())
if "text" in payload:
raw += payload["text"]
# a cheap progress signal: which sections have arrived
done = [k for k in ("ingredients", "steps", "coverage_check")
if f'"{k}"' in raw]
print("\r".join([]) or f"{len(raw)} chars, sections: {done}", end="\r")
recipe = json.loads(raw)
print()
print(recipe["recipe_name"], "|", recipe["fit"])
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
Accept: "text/event-stream",
},
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let raw = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const payload = JSON.parse(line.slice(5).trim());
if (payload.text) raw += payload.text;
}
}
const recipe = JSON.parse(raw);
console.log(recipe.recipe_name, recipe.fit);
body, _ := json.Marshal(input)
req, _ := http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
scanner := bufio.NewScanner(res.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
var payload struct{ Text string `json:"text"` }
if json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &payload) == nil {
raw.WriteString(payload.Text)
}
}
fmt.Println(raw.Len(), "chars of recipe JSON")
var body = HttpRequest.BodyPublishers.ofString(input);
var req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(body)
.build();
var raw = new StringBuilder();
HTTP.send(req, HttpResponse.BodyHandlers.ofLines()).body()
.filter(l -> l.startsWith("data:"))
.forEach(l -> {
var payload = l.substring(5).trim();
int i = payload.indexOf("\"text\":\"");
if (i >= 0) raw.append(payload.substring(i + 8, payload.lastIndexOf('"')));
});
System.out.println(raw.length() + " chars of recipe JSON");
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req["Accept"] = "text/event-stream"
req.body = JSON.generate(INPUT)
raw = +""
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
next unless line.start_with?("data:")
payload = JSON.parse(line[5..].strip) rescue next
raw << payload["text"] if payload["text"]
end
end
end
end
recipe = JSON.parse(raw)
puts "#{recipe['recipe_name']} | #{recipe['fit']}"
<?php
$raw = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($input));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
"Accept: text/event-stream",
]);
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$raw) {
foreach (explode("\n", $chunk) as $line) {
if (strncmp($line, "data:", 5) !== 0) {
continue;
}
$payload = json_decode(trim(substr($line, 5)), true);
if (isset($payload["text"])) {
$raw .= $payload["text"];
}
}
return strlen($chunk);
});
curl_exec($ch);
curl_close($ch);
$recipe = json_decode($raw, true);
echo $recipe["recipe_name"], " | ", $recipe["fit"], PHP_EOL;
var req = new HttpRequestMessage(HttpMethod.Post, $"{Base}/run-stream");
req.Headers.Add("Authorization", $"Bearer {Token}");
req.Headers.Add("Idempotency-Key", key);
req.Headers.Add("Accept", "text/event-stream");
req.Content = new StringContent(JsonSerializer.Serialize(input),
Encoding.UTF8, "application/json");
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
while (await reader.ReadLineAsync() is { } line) {
if (!line.StartsWith("data:")) continue;
var payload = JsonSerializer.Deserialize<JsonElement>(line[5..].Trim());
if (payload.TryGetProperty("text", out var text)) raw.Append(text.GetString());
}
var recipe = JsonSerializer.Deserialize<JsonElement>(raw.ToString());
Console.WriteLine(recipe.GetProperty("recipe_name").GetString());
The output contract
One JSON object, every key present. Read this from here rather than from a sample: it is taken from the app's own normalizer, which is what the renderer relies on.
| field | type | meaning |
|---|---|---|
recipe_name | string | The dish. |
fit | enum | makes-it (nothing to buy) · needs-a-substitute (something stood in for, still nothing to buy) · needs-a-shop (at least one purchase). |
verdict | string | One sentence naming the single fact that decided the fit. Not a summary of the recipe. |
cuisine, meal | string | Free text; meal echoes the request. |
servings | number | Equals the requested servings. |
difficulty | enum | beginner · comfortable · confident. |
active_minutes, total_minutes | number | Hands-on time and time to plate. active_minutes never exceeds total_minutes, and the step minutes sum to within three of it. |
equipment_used | array of strings | A subset of the requested equipment. |
exec_summary | string | Two to four sentences. |
assumptions, open_questions | array of strings | What had to be assumed, and what would change the method rather than the flavour. |
ingredients[] | array of objects | {item, qty, unit, prep, role, from, note}. role is core · supporting · seasoning · optional. from is have · substitute · staple · shop. unit is one of g, kg, ml, l, tsp, tbsp, cup, piece, clove, slice, tin, bunch, handful, pinch, sprig, or empty. |
substitutions[] | array of objects | {missing, use, ratio, why, changes}. changes is the field that makes it useful: what the swap does to the dish. |
shopping_list | array of strings | Empty when fit is makes-it. |
steps[] | array of objects | {id, target, minutes, heat, hands_off, action, doneness}. id runs from ST-01. target is stable for the same work across a revision, which is what makes the diff possible. |
timeline | array of strings | What to do during the unattended stretches. |
scaling_note | string | What does not scale linearly for this dish. |
allergens | array of enums | From: dairy, gluten, egg, peanut, tree nut, soy, fish, shellfish, mollusc, sesame, mustard, celery, lupin, sulphite. Values outside this vocabulary are dropped by the normalizer. |
safety | array of strings | One to four handling lines specific to the dish, temperatures in both C and F. |
leftovers, why_this, summary | string | Closing prose. |
coverage_check[] | array of objects | {id, addressed, note} — one per pantry_facts.flags id, exactly once each. |
variations[] | array of objects | Exactly three {name, goal, how}. Each goal is designed to be handed straight back as revise.goal. |
revision_note | string | Empty on a first run; on a revision, what changed, what it cost and what was kept. |
Invariants worth asserting in your own code
The web page checks all of these and shows the reader every disagreement. If you build on the API, these are the assertions that catch a bad reply before it reaches a kitchen:
fit == "makes-it"implies an emptyshopping_listAND no ingredient withfrom == "shop".fit == "needs-a-shop"implies at least one of those.- Every
from == "substitute"ingredient has a matchingsubstitutionsentry; everyfrom == "shop"ingredient appears inshopping_list. sum(steps[].minutes)is within 3 ofactive_minutes, andactive_minutes <= total_minutes <= minuteswhen a budget was set.servingsequals what you sent;equipment_usedis a subset of what you sent.- No ingredient, substitution or shopping-list entry names anything in
avoid. must_use, if sent, appears iningredients.steps[].idisST-01,ST-02, … in order.- The set of
coverage_check[].idequals the set ofpantry_facts.flags[].id— no gaps, no extras, no duplicates. len(variations) == 3.- Your own allergen detection over
ingredients[].itemfinds nothing thatallergensomits. If you write one: match on word boundaries, and exclude the dairy words when a plant qualifier is present, orcoconut milkandpeanut butterwill read as dairy.
7. The second run: scaling is free, revising is not
Two different things, and it matters which one you reach for.
Changing the number of people needs no run at all. Quantities come back as {qty, unit}, so scaling is a multiplication. Do it yourself: multiply by target / servings, keep masses and volumes exact, and round counts of whole things (piece, clove, slice, tin) to the nearest half — reporting the rounding rather than hiding it. Do not scale role == "seasoning" linearly past about 1.5x; salt and chilli in a bigger pot do not follow the multiplier.
Changing the dish is one paid run — send revise alongside the same input. of is the previous recipe name, goal is what to change in plain words, and previous is a compact form of the previous recipe: its name, its ingredients with quantities, and its step targets. Sending the whole previous reply back doubles the input cost of every revision for nothing — what the model needs is what it made and where, not its own prose about it. Use a different Idempotency-Key from the run being revised; it is a different run.
# Free: scale the ingredients yourself. No API call.
printf '%s' "$RECIPE" | python3 -c '
import json, sys
r = json.load(sys.stdin)
target = 6
factor = target / r["servings"]
WHOLE = {"piece", "clove", "slice", "tin", "bunch", "handful", "pinch", "sprig"}
for i in r["ingredients"]:
raw = i["qty"] * (1.5 + (factor - 1.5) * 0.6 if i["role"] == "seasoning" and factor > 1.5 else factor)
if i["unit"] in WHOLE:
snapped = max(0.5, round(raw * 2) / 2)
note = "" if abs(snapped - raw) < 0.04 else " (rounded from %.2f)" % raw
print("%-8s %s %s%s" % (snapped, i["unit"], i["item"], note))
else:
print("%-8s %s %s" % (round(raw, 1), i["unit"], i["item"]))'
# Paid: revise the dish. One run.
REVISE=$(printf '%s' "$RECIPE" | python3 -c '
import json, sys
r = json.load(sys.stdin)
compact = r["recipe_name"] + " - serves %d, %d min\n" % (r["servings"], r["total_minutes"])
compact += "Ingredients: " + "; ".join(
"%s %s %s" % (i["qty"], i["unit"], i["item"]) for i in r["ingredients"]) + "\n"
compact += "Steps: " + " ".join(
"%d) %s, %d min" % (n, s["target"], s["minutes"]) for n, s in enumerate(r["steps"], 1))
print(json.dumps({"of": r["recipe_name"], "goal": "make it work in 20 minutes",
"previous": compact}))')
BODY=$(printf '%s' "$INPUT" | python3 -c '
import json, sys, os
inp = json.load(sys.stdin)
inp["revise"] = json.loads(os.environ["REVISE"])
print(json.dumps(inp))' )
call run "$BODY" # with a NEW Idempotency-Key
WHOLE = {"piece", "clove", "slice", "tin", "bunch", "handful", "pinch", "sprig"}
def scale(recipe, target):
"""Free. No API call: the quantities are already structured."""
factor = target / recipe["servings"]
out = []
for i in recipe["ingredients"]:
use = factor
if i["role"] == "seasoning" and factor > 1.5:
use = 1.5 + (factor - 1.5) * 0.6 # seasoning is not linear
raw = i["qty"] * use
if i["unit"] in WHOLE:
snapped = max(0.5, round(raw * 2) / 2)
out.append(dict(i, qty=snapped, rounded=abs(snapped - raw) > 0.04, exact=raw))
else:
out.append(dict(i, qty=raw, rounded=False, exact=raw))
return out
def compact(recipe):
"""What the model needs to revise its own work - not its prose about it."""
lines = [f"{recipe['recipe_name']} - serves {recipe['servings']}, "
f"{recipe['active_minutes']} min active of {recipe['total_minutes']} total"]
lines.append("Ingredients: " + "; ".join(
f"{i['qty']} {i['unit']} {i['item']}" for i in recipe["ingredients"]))
lines.append("Steps: " + " ".join(
f"{n}) {s['target']}, {s['minutes']} min" for n, s in enumerate(recipe["steps"], 1)))
return "\n".join(lines)
# Paid: one run.
revised_input = dict(INPUT, revise={
"of": recipe["recipe_name"],
"goal": "make it work in 20 minutes",
"previous": compact(recipe),
})
job = call("run", revised_input, idempotency_key=key + ":rev")
const WHOLE = new Set(["piece", "clove", "slice", "tin", "bunch", "handful", "pinch", "sprig"]);
// Free. No API call: the quantities are already structured.
function scale(recipe, target) {
const factor = target / recipe.servings;
return recipe.ingredients.map((i) => {
let use = factor;
if (i.role === "seasoning" && factor > 1.5) use = 1.5 + (factor - 1.5) * 0.6;
const raw = i.qty * use;
if (!WHOLE.has(i.unit)) return { ...i, qty: raw, rounded: false, exact: raw };
const snapped = Math.max(0.5, Math.round(raw * 2) / 2);
return { ...i, qty: snapped, rounded: Math.abs(snapped - raw) > 0.04, exact: raw };
});
}
// What the model needs to revise its own work - not its prose about it.
function compact(recipe) {
return [
`${recipe.recipe_name} - serves ${recipe.servings}, ${recipe.total_minutes} min`,
"Ingredients: " + recipe.ingredients
.map((i) => `${i.qty} ${i.unit} ${i.item}`).join("; "),
"Steps: " + recipe.steps
.map((s, n) => `${n + 1}) ${s.target}, ${s.minutes} min`).join(" "),
].join("\n");
}
// Paid: one run.
const revisedInput = {
...INPUT,
revise: { of: recipe.recipe_name, goal: "make it work in 20 minutes", previous: compact(recipe) },
};
const job = await call("run", revisedInput, key + ":rev");
var whole = map[string]bool{"piece": true, "clove": true, "slice": true, "tin": true,
"bunch": true, "handful": true, "pinch": true, "sprig": true}
// Free. No API call: the quantities are already structured.
func scale(ings []Ingredient, from, to int) []Ingredient {
factor := float64(to) / float64(from)
out := make([]Ingredient, 0, len(ings))
for _, i := range ings {
use := factor
if i.Role == "seasoning" && factor > 1.5 {
use = 1.5 + (factor-1.5)*0.6 // seasoning is not linear
}
raw := i.Qty * use
if whole[i.Unit] {
snapped := math.Max(0.5, math.Round(raw*2)/2)
i.Rounded = math.Abs(snapped-raw) > 0.04
i.Exact = raw
i.Qty = snapped
} else {
i.Qty = raw
}
out = append(out, i)
}
return out
}
// Paid: one run. Add revise to the same input and use a NEW idempotency key.
input["revise"] = map[string]string{
"of": recipe.Name,
"goal": "make it work in 20 minutes",
"previous": compact(recipe),
}
raw, err = call("run", input, key+":rev")
// Free: scale the ingredients in your own code. No API call.
// Masses and volumes scale exactly; counts of whole things round to the nearest
// half with the exact value kept; seasoning is not linear past 1.5x.
static final Set<String> WHOLE = Set.of("piece", "clove", "slice", "tin",
"bunch", "handful", "pinch", "sprig");
static double scaleQty(double qty, String unit, String role, double factor) {
double use = "seasoning".equals(role) && factor > 1.5
? 1.5 + (factor - 1.5) * 0.6 : factor;
double raw = qty * use;
if (!WHOLE.contains(unit)) return raw;
return Math.max(0.5, Math.round(raw * 2) / 2.0);
}
// Paid: add a revise object to the same input and send a NEW Idempotency-Key.
// revise = {"of": <previous recipe_name>,
// "goal": "make it work in 20 minutes",
// "previous": <name + ingredients + step targets, nothing more>}
var job = call("run", revisedInput, key + ":rev");
WHOLE = %w[piece clove slice tin bunch handful pinch sprig].freeze
# Free. No API call: the quantities are already structured.
def scale(recipe, target)
factor = target.to_f / recipe["servings"]
recipe["ingredients"].map do |i|
use = i["role"] == "seasoning" && factor > 1.5 ? 1.5 + (factor - 1.5) * 0.6 : factor
raw = i["qty"] * use
next i.merge("qty" => raw) unless WHOLE.include?(i["unit"])
snapped = [0.5, (raw * 2).round / 2.0].max
i.merge("qty" => snapped, "rounded" => (snapped - raw).abs > 0.04, "exact" => raw)
end
end
# What the model needs to revise its own work - not its prose about it.
def compact(recipe)
[
"#{recipe['recipe_name']} - serves #{recipe['servings']}, #{recipe['total_minutes']} min",
"Ingredients: " + recipe["ingredients"].map { |i| "#{i['qty']} #{i['unit']} #{i['item']}" }.join("; "),
"Steps: " + recipe["steps"].each_with_index.map { |s, n| "#{n + 1}) #{s['target']}, #{s['minutes']} min" }.join(" ")
].join("\n")
end
# Paid: one run.
revised = INPUT.merge(revise: {
of: recipe["recipe_name"],
goal: "make it work in 20 minutes",
previous: compact(recipe)
})
job = call("run", revised, idempotency_key: "#{key}:rev")
<?php
const WHOLE = ["piece", "clove", "slice", "tin", "bunch", "handful", "pinch", "sprig"];
// Free. No API call: the quantities are already structured.
function scale(array $recipe, int $target): array {
$factor = $target / $recipe["servings"];
return array_map(function (array $i) use ($factor) {
$use = ($i["role"] === "seasoning" && $factor > 1.5)
? 1.5 + ($factor - 1.5) * 0.6 : $factor;
$raw = $i["qty"] * $use;
if (!in_array($i["unit"], WHOLE, true)) {
return array_merge($i, ["qty" => $raw]);
}
$snapped = max(0.5, round($raw * 2) / 2);
return array_merge($i, [
"qty" => $snapped,
"rounded" => abs($snapped - $raw) > 0.04,
"exact" => $raw,
]);
}, $recipe["ingredients"]);
}
// Paid: one run. Add revise to the same input, with a NEW Idempotency-Key.
$input["revise"] = [
"of" => $recipe["recipe_name"],
"goal" => "make it work in 20 minutes",
"previous" => compactRecipe($recipe),
];
$job = call("run", $input, $key . ":rev");
static readonly HashSet<string> Whole = new() {
"piece", "clove", "slice", "tin", "bunch", "handful", "pinch", "sprig"
};
// Free. No API call: the quantities are already structured.
static (double Qty, bool Rounded, double Exact) ScaleQty(
double qty, string unit, string role, double factor) {
var use = role == "seasoning" && factor > 1.5 ? 1.5 + (factor - 1.5) * 0.6 : factor;
var raw = qty * use;
if (!Whole.Contains(unit)) return (raw, false, raw);
var snapped = Math.Max(0.5, Math.Round(raw * 2) / 2);
return (snapped, Math.Abs(snapped - raw) > 0.04, raw);
}
// Paid: one run. Add revise to the same input, with a NEW Idempotency-Key.
var revised = new {
// ...every field of the original input...
revise = new {
of = recipeName,
goal = "make it work in 20 minutes",
previous = Compact(recipe), // name + ingredients + step targets, nothing more
},
};
var job = await Call("run", revised, key + ":rev");
Truncation and partial results
If the balance sits between min_credits and hold_credits, the run still executes with a reduced output cap and the job comes back with "truncated": true. The reply is then a valid prefix rather than a valid object, and the honest thing to do is render what parsed and say so — the web page recovers the sections that arrived and labels the recipe as cut short rather than presenting a method missing its last three steps as complete. Never plate a truncated recipe as a whole one.
On a reply that does not parse at all, resend with retry_note naming the failure and reuse an idempotency key derived from the same input with the attempt counter bumped — that is what stops a formatting blip billing twice.
Storage
Saved recipes live in a declared collection called cooks, reachable at /v1/app-api/collections/cooks with POST /records, POST /query, POST /similar, and GET|PUT|DELETE /records/{id}. Two things about it are worth knowing before you read a record: the document nests under doc ({record_id, doc: {...}}) — reading fields flat off the record yields undefined for everything; and records are scoped to the calling subject, so a script that mints a fresh guest token per call sees an empty collection while the rows sit safely under the previous guest identity. Reuse one token.
query takes {where, sort, limit} where every where entry is an operator object ({"diet": {"eq": "vegan"}}, not {"diet": "vegan"}) and the ordering key is sort, an object — order_by is silently ignored and the query quietly falls back to newest-first. similar takes {text, limit} and searches the embedded fields (title, pantry_line, cuisine), so “the one with the chicken and the tinned tomatoes” finds a recipe whose name says neither. It is rate-limited to 30 requests a minute per IP and costs an order of magnitude more than a where filter, so debounce it and never fire it per keystroke.