← Recipe Generator / API
Tokens

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 bodyPOST /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

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate first and top up.
forbidden403The 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_found404Unknown job id, unknown collection, or the app slug does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
validation_error422The 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_limited429Too many requests. Back off and retry; do not tight-loop.
internal5xxA 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":"..."}}

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
}

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}}

4. Price the run — free

The input object is exactly what the app's own form submits:

fieldtypemeaning
havestring, requiredThe 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_usestring, optionalThe thing that will go off. It must appear in ingredients and carry real weight in the dish rather than turning up as a garnish.
avoidstring, optionalAllergies, 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.
dietstringOne 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.
equipmentarray of stringsWhat 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.
minutesnumberThe 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.
servingsnumber1 to 12. The quantities are written for exactly this number. You do not need a second run to change it — see step 8.
skillstringbeginner, 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.
mealstringany, breakfast, lunch, dinner, side, snack, dessert.
notesstring, optionalFree 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_factsobject{items: [{id,label}], flags: [{id,label}]} — see below.
reviseobject, optional{of, goal, previous} — the second run. See step 8.
retry_notestring, optionalSend 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:

idfires when
PANTRY-THINFewer than four non-staple ingredients were recognised.
NO-QTYSome entries carry no quantity, so the recipe has to choose one.
NO-PROTEINNothing reads as a protein — meat, fish, egg, dairy, beans, lentils, tofu, nuts.
NO-FATNo cooking fat beyond the assumed neutral oil.
NO-AROMATICNo onion, garlic, ginger, chilli, leek or celery.
NO-ACIDNothing acidic — no lemon, lime, vinegar, tomato, wine or yoghurt.
NO-CARBNo 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-CONFLICTAn item in the list is excluded by the chosen diet. It is set aside, not cooked with.
AVOID-CONFLICTAn item in the list matches something in avoid. Avoid wins.
MUST-USE-ABSENTmust_use names something the parser could not find in have.
TIME-TIGHTThe budget is 20 minutes or less and the list contains something that needs an hour.
EQUIP-NONENo equipment was listed at all.
EQUIP-NOCOOKno-cook was sent alongside a heat source.
SERVINGS-HIGHMore than eight servings, where one domestic pan stops browning properly.
RAW-RISKThe list contains something whose handling matters — poultry, pork, mince, eggs, shellfish, rice. The recipe owes a temperature or a rule, not just a time.
PERISHABLESomething 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.

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"))'

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}

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.

fieldtypemeaning
recipe_namestringThe dish.
fitenummakes-it (nothing to buy) · needs-a-substitute (something stood in for, still nothing to buy) · needs-a-shop (at least one purchase).
verdictstringOne sentence naming the single fact that decided the fit. Not a summary of the recipe.
cuisine, mealstringFree text; meal echoes the request.
servingsnumberEquals the requested servings.
difficultyenumbeginner · comfortable · confident.
active_minutes, total_minutesnumberHands-on time and time to plate. active_minutes never exceeds total_minutes, and the step minutes sum to within three of it.
equipment_usedarray of stringsA subset of the requested equipment.
exec_summarystringTwo to four sentences.
assumptions, open_questionsarray of stringsWhat 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_listarray of stringsEmpty 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.
timelinearray of stringsWhat to do during the unattended stretches.
scaling_notestringWhat does not scale linearly for this dish.
allergensarray of enumsFrom: dairy, gluten, egg, peanut, tree nut, soy, fish, shellfish, mollusc, sesame, mustard, celery, lupin, sulphite. Values outside this vocabulary are dropped by the normalizer.
safetyarray of stringsOne to four handling lines specific to the dish, temperatures in both C and F.
leftovers, why_this, summarystringClosing prose.
coverage_check[]array of objects{id, addressed, note} — one per pantry_facts.flags id, exactly once each.
variations[]array of objectsExactly three {name, goal, how}. Each goal is designed to be handed straight back as revise.goal.
revision_notestringEmpty 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:

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

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.