← Ladle List
Ladle List API
Tokens

Drive Ladle List from your own code

The one metered task, trip, is available over HTTP. It takes a week of soups as the page's merged shopping list - every item with its section, the total needed, which soups use it and its tags - and returns a shop plan: an amount to pick up for every item to buy and which shop to buy it at, what each amount covers, where each part-used loose end goes, swaps that cut a loose end, which soup to cook first, and what to check in the cupboard.

The division of labour first: the page owns the list and the rule; the model owns the amounts and the words; the page re-checks the reply. The merged rows, the sections, the cupboard strike-off, the loose ends and the verdict come from ladlekit.js, which you can run yourself (see Build the input yourself).

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":"ladle-list"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field (task, soups, rows, figures, page_verdict, buy_rows) is missing.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: the shop plan is metered and needs a personal token.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field and the verdict rule

Every run sends task: trip (plan the shop); a missing or different one is still answered as trip. The verdict follows a fixed rule on the number of loose ends - pack-size items (fresh herbs, celery, leafy greens, cream, crème fraîche, yogurt, ginger, spring onions and a few more) that only one soup uses and that soup does not ask for the whole bunch, bag or carton:

  1. tight - no loose ends. No swaps.
  2. loose_ends - one or two. Zero to two swaps.
  3. scattered - three or more. One to three swaps.

The reply must copy page_verdict; the page flags any reply that does not.

The run input

Every value is a string (objects are never sent). app.js builds it with LadleKit.buildInput(form).input.

FieldRequiredWhat it holds
taskyesAlways trip.
soupsyesEach soup's name and servings, then its ingredient lines as typed (at most 30 per soup, six soups).
pantrynoWhat is already in the cupboard, joined with ; - or nothing listed.
shopsnoUp to four shop names, joined with ; . Defaults to supermarket.
rowsyesThe merged list, one line per item: id | section | item | need <total> | used in: <soup> <amount>; ... | tags. Tags: buy, have, check, shared by N soups, pack-size, bought whole, loose end.
figuresyesThe page's counts: soups, servings, items, to buy, already have, check in the cupboard, shared, pack-size items to buy, loose ends.
page_verdictyestight, loose_ends or scattered, from the rule above.
buy_rowsyesThe ids to buy, in list order.
loose_rowsnoThe loose-end ids, or none.
check_rowsnoThe staples to check in the cupboard (salt, pepper, oil, sugar, flour not listed in the pantry), or none.
clip_notenoWhat the page left out (lines past 30 per soup; water), or empty.
retry_notenoSent only on the page's one reformat retry.

The reply

The reply is one JSON object in job.output.output. The page parses it with LadleKit.parseReply and LadleKit.normalize (a cut-off stream is closed by LadleKit.closeJson and shown as "N of 6 sections recovered").

{"task": "trip", "verdict": "tight | loose_ends | scattered", "title": "...", "summary": "...",
 "buy":      [{"row": "r1", "shop": "one of your shops", "get": "what to pick up", "covers": "what it covers"}],
 "spare":    [{"row": "r5", "left": "roughly what is left", "use": "where the rest goes"}],
 "swaps":    [{"row": "r5", "soup": "an exact soup name", "swap": "the change", "saves": "what is no longer bought"}],
 "cook_first": {"soup": "an exact soup name", "why": "..."},
 "cupboard": [{"row": "r13", "ask": "a yes/no check"}]}
FieldContract
buyExactly one entry per id in buy_rows, in order; get is never less than the need and never names a pack weight, a price or a brand.
spareExactly one entry per id in loose_rows; empty when there are none.
swapsCounted by verdict (above); each changes a loose-end item in a soup that uses it.
cupboardOne entry per id in check_rows.

The page then checks the reply against its own list (LadleKit.check): the verdict, items to buy that were not planned or planned twice, ids it never listed, shops that are not yours, loose ends without a home, the swap count, swaps in a soup that does not use the item, soup names, cupboard checks and item ids leaking into the wording.

Worked examples, one per verdict

These are the three free samples on the page: the exact input and a stand-in model's reply, unedited.

Two tomato soups - tight

Input:

{
 "task": "trip",
 "soups": "Tomato and red lentil - serves 4\n  - 1 onion\n  - 2 carrots\n  - 2 celery stalks\n  - 3 garlic cloves\n  - 1 cup red lentils\n  - 1 can chopped tomatoes\n  - 1 litre vegetable stock\n  - 1 tsp ground cumin\n  - 2 tbsp olive oil\n  - salt and pepper\nMinestrone - serves  ... (501 characters)",
 "pantry": "olive oil; salt",
 "shops": "supermarket",
 "rows": "r1 | Fruit and veg | onion | need 2 | used in: Tomato and red lentil 1; Minestrone 1 | buy, shared by 2 soups\nr2 | Fruit and veg | carrot | need 4 | used in: Tomato and red lentil 2; Minestrone 2 | buy, shared by 2 soups\nr3 | Fruit and veg | celery | need 4 st ... (1638 characters)",
 "figures": "soups 2; servings 10; items 15; to buy 12; already have 2; check in the cupboard 1; shared by two or more soups 6; pack-size items to buy 2; loose ends 0",
 "page_verdict": "tight",
 "buy_rows": "r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r14",
 "loose_rows": "none",
 "check_rows": "r13",
 "clip_note": "water is not on the list (1 line)"
}

Reply:

{
 "task": "trip",
 "verdict": "tight",
 "title": "Two soups, one quick supermarket trip",
 "summary": "You have 2 soups this week, with 12 items to buy and 6 of them shared between soups. This is a tight shop, so there are no loose ends to sort out. Water was left out of the list.",
 "buy": [
  {
   "row": "r1",
   "shop": "supermarket",
   "get": "2 onions",
   "covers": "1 for Tomato and red lentil and 1 for Minestrone"
  },
  {
   "row": "r2",
   "shop": "supermarket",
   "get": "4 carrots",
   "covers": "2 for Tomato and red lentil and 2 for Minestrone"
  },
  {
   "row": "r3",
   "shop": "supermarket",
   "get": "1 head of celery",
   "covers": "2 stalks for Tomato and red lentil and 2 for Minestrone"
  },
  {
   "row": "r4",
   "shop": "supermarket",
   "get": "1 bulb of garlic",
   "covers": "3 cloves for Tomato and red lentil"
  },
  {
   "row": "r5",
   "shop": "supermarket",
   "get": "1 bunch of basil",
   "covers": "1 bunch for Minestrone"
  },
  {
   "row": "r6",
   "shop": "supermarket",
   "get": "a small piece of parmesan rind",
   "covers": "some for Minestrone"
  },
  {
   "row": "r7",
   "shop": "supermarket",
   "get": "3 cans of chopped tomatoes",
   "covers": "1 can for Tomato and red lentil and 2 cans for Minestrone"
  },
  {
   "row": "r8",
   "shop": "supermarket",
   "get": "1 can of cannellini beans",
   "covers": "1 can for Minestrone"
  },
  {
   "row": "r9",
   "shop": "supermarket",
   "get": "the smallest bag of red lentils",
   "covers": "1 cup for Tomato and red lentil"
  },
  {
   "row": "r10",
   "shop": "supermarket",
   "get": "the smallest bag of ditalini",
   "covers": "100 g for Minestrone"
  },
  {
   "row": "r11",
   "shop": "supermarket",
   "get": "a jar of cumin",
   "covers": "1 tsp for Tomato and red lentil"
  },
  {
   "row": "r14",
   "shop": "supermarket",
   "get": "enough cartons of vegetable stock for 2½ litres",
   "covers": "1 litre for Tomato and red lentil and 1½ litres for Minestrone"
  }
 ],
 "spare": [],
 "swaps": [],
 "cook_first": {
  "soup": "Minestrone",
  "why": "It has the fresh basil, the most delicate ingredient this week."
 },
 "cupboard": [
  {
   "row": "r13",
   "ask": "Is there any black pepper?"
  }
 ]
}

Squash, leek and potato, chicken noodle - loose ends

Input:

{
 "task": "trip",
 "soups": "Butternut squash - serves 4\n  - 1 butternut squash\n  - 1 onion\n  - 2 garlic cloves\n  - 1 litre vegetable stock\n  - 100 ml double cream\n  - 1 tsp ground nutmeg\n  - salt and pepper\nLeek and potato - serves 4\n  - 3 leeks\n  - 4 potatoes\n  - 1 onion\n  - 30 g butter ... (482 characters)",
 "pantry": "butter; salt; pepper; nutmeg",
 "shops": "supermarket; butcher",
 "rows": "r1 | Fruit and veg | butternut squash | need 1 | used in: Butternut squash 1 | buy\nr2 | Fruit and veg | onion | need 3 | used in: Butternut squash 1; Leek and potato 1; Chicken noodle 1 | buy, shared by 3 soups\nr3 | Fruit and veg | garlic | need 2 cloves | use ... (1691 characters)",
 "figures": "soups 3; servings 12; items 18; to buy 14; already have 4; check in the cupboard 0; shared by two or more soups 3; pack-size items to buy 3; loose ends 2",
 "page_verdict": "loose_ends",
 "buy_rows": "r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r12, r13, r17, r18",
 "loose_rows": "r8, r10",
 "check_rows": "none",
 "clip_note": ""
}

Reply:

{
 "task": "trip",
 "verdict": "loose_ends",
 "title": "Two shops, three soups, a few loose ends",
 "summary": "You're making 3 soups and buying 14 items, 3 of them shared across more than one soup. You have a couple of loose ends to tidy up before the shop is complete.",
 "buy": [
  {
   "row": "r1",
   "shop": "supermarket",
   "get": "1 butternut squash",
   "covers": "1 for Butternut squash"
  },
  {
   "row": "r2",
   "shop": "supermarket",
   "get": "3 onions",
   "covers": "1 for Butternut squash, 1 for Leek and potato and 1 for Chicken noodle"
  },
  {
   "row": "r3",
   "shop": "supermarket",
   "get": "1 bulb of garlic",
   "covers": "2 cloves for Butternut squash"
  },
  {
   "row": "r4",
   "shop": "supermarket",
   "get": "3 leeks",
   "covers": "3 for Leek and potato"
  },
  {
   "row": "r5",
   "shop": "supermarket",
   "get": "4 potatoes",
   "covers": "4 for Leek and potato"
  },
  {
   "row": "r6",
   "shop": "supermarket",
   "get": "2 carrots",
   "covers": "2 for Chicken noodle"
  },
  {
   "row": "r7",
   "shop": "supermarket",
   "get": "1 bunch of chives",
   "covers": "2 tbsp for Leek and potato and 1 tbsp for Chicken noodle"
  },
  {
   "row": "r8",
   "shop": "supermarket",
   "get": "1 bunch of parsley",
   "covers": "1 handful for Chicken noodle"
  },
  {
   "row": "r9",
   "shop": "butcher",
   "get": "4 chicken thighs",
   "covers": "4 for Chicken noodle"
  },
  {
   "row": "r10",
   "shop": "supermarket",
   "get": "1 carton of cream",
   "covers": "100 ml for Butternut squash"
  },
  {
   "row": "r12",
   "shop": "supermarket",
   "get": "1 carton of milk",
   "covers": "150 ml for Leek and potato"
  },
  {
   "row": "r13",
   "shop": "supermarket",
   "get": "the smallest packet of egg noodles",
   "covers": "100 g for Chicken noodle"
  },
  {
   "row": "r17",
   "shop": "supermarket",
   "get": "1 carton of vegetable stock",
   "covers": "1 l for Butternut squash"
  },
  {
   "row": "r18",
   "shop": "supermarket",
   "get": "3 cartons of chicken stock",
   "covers": "1 l for Leek and potato and 1½ l for Chicken noodle"
  }
 ],
 "spare": [
  {
   "row": "r8",
   "left": "most of the bunch of parsley",
   "use": "Scatter the rest over Butternut squash soup as a garnish."
  },
  {
   "row": "r10",
   "left": "some of the carton of cream",
   "use": "Stir the rest into Leek and potato soup to enrich it."
  }
 ],
 "swaps": [
  {
   "row": "r8",
   "soup": "Chicken noodle",
   "swap": "Use the chives already on the list instead of parsley.",
   "saves": "a handful of parsley"
  }
 ],
 "cook_first": {
  "soup": "Chicken noodle",
  "why": "It has both fresh chives and parsley, the most delicate items of the three soups."
 },
 "cupboard": []
}

Four soups - scattered

Input:

{
 "task": "trip",
 "soups": "Carrot and coriander - serves 4\n  - 6 carrots\n  - 1 onion\n  - 1 litre vegetable stock\n  - 1 tsp ground coriander\n  - 3 tbsp fresh coriander\n  - 1 tbsp olive oil\nCoconut squash - serves 4\n  - 1 butternut squash\n  - 1 tin coconut milk\n  - 1 thumb ginger\n  - 1 tb ... (615 characters)",
 "pantry": "olive oil, butter, salt, black pepper, ground coriander",
 "shops": "supermarket; market",
 "rows": "r1 | Fruit and veg | carrot | need 6 | used in: Carrot and coriander 6 | buy\nr2 | Fruit and veg | onion | need 2 | used in: Carrot and coriander 1; Mushroom 1 | buy, shared by 2 soups\nr3 | Fruit and veg | butternut squash | need 1 | used in: Coconut squash 1 | ... (1916 characters)",
 "figures": "soups 4; servings 14; items 20; to buy 17; already have 3; check in the cupboard 0; shared by two or more soups 2; pack-size items to buy 6; loose ends 6",
 "page_verdict": "scattered",
 "buy_rows": "r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12, r14, r15, r17, r19, r20",
 "loose_rows": "r4, r9, r10, r11, r12, r14",
 "check_rows": "none",
 "clip_note": ""
}

Reply:

{
 "task": "trip",
 "verdict": "scattered",
 "title": "Four soups, one scattered shop",
 "summary": "You have 4 soups to shop for, with 17 items to buy and 2 shared between soups. The trip is scattered, so expect several loose ends along the way.",
 "buy": [
  {
   "row": "r1",
   "shop": "market",
   "get": "6 carrots",
   "covers": "6 for Carrot and coriander"
  },
  {
   "row": "r2",
   "shop": "market",
   "get": "2 onions",
   "covers": "1 for Carrot and coriander and 1 for Mushroom"
  },
  {
   "row": "r3",
   "shop": "market",
   "get": "1 butternut squash",
   "covers": "1 for Coconut squash"
  },
  {
   "row": "r4",
   "shop": "market",
   "get": "1 piece of ginger",
   "covers": "1 thumb for Coconut squash"
  },
  {
   "row": "r5",
   "shop": "market",
   "get": "1 lime",
   "covers": "1 for Coconut squash"
  },
  {
   "row": "r6",
   "shop": "market",
   "get": "400 g of mushrooms",
   "covers": "400 g for Mushroom"
  },
  {
   "row": "r7",
   "shop": "market",
   "get": "1 bulb of garlic",
   "covers": "2 cloves for Mushroom"
  },
  {
   "row": "r8",
   "shop": "market",
   "get": "1 shallot",
   "covers": "1 for Pea and mint"
  },
  {
   "row": "r9",
   "shop": "market",
   "get": "1 bunch of coriander",
   "covers": "3 tbsp for Carrot and coriander"
  },
  {
   "row": "r10",
   "shop": "market",
   "get": "1 bunch of thyme",
   "covers": "2 sprigs for Mushroom"
  },
  {
   "row": "r11",
   "shop": "market",
   "get": "1 bunch of mint",
   "covers": "1 handful for Pea and mint"
  },
  {
   "row": "r12",
   "shop": "supermarket",
   "get": "1 carton of cream",
   "covers": "150 ml for Mushroom"
  },
  {
   "row": "r14",
   "shop": "supermarket",
   "get": "1 pot of creme fraiche",
   "covers": "2 tbsp for Pea and mint"
  },
  {
   "row": "r15",
   "shop": "supermarket",
   "get": "1 can of coconut milk",
   "covers": "1 can for Coconut squash"
  },
  {
   "row": "r17",
   "shop": "supermarket",
   "get": "3½ litres of vegetable stock",
   "covers": "1 litre for Carrot and coriander, 1 litre for Coconut squash, 1 litre for Mushroom and 500 ml for Pea and mint"
  },
  {
   "row": "r19",
   "shop": "supermarket",
   "get": "1 jar of red curry paste",
   "covers": "1 tbsp for Coconut squash"
  },
  {
   "row": "r20",
   "shop": "supermarket",
   "get": "500 g of frozen peas",
   "covers": "500 g for Pea and mint"
  }
 ],
 "spare": [
  {
   "row": "r4",
   "left": "most of the piece of ginger",
   "use": "Freeze the rest flat in a bag."
  },
  {
   "row": "r9",
   "left": "most of the bunch of coriander",
   "use": "Use the rest as a garnish for Coconut squash."
  },
  {
   "row": "r10",
   "left": "most of the bunch of thyme",
   "use": "Freeze the rest chopped in oil."
  },
  {
   "row": "r11",
   "left": "most of the bunch of mint",
   "use": "Keep the rest in the fridge in a damp towel."
  },
  {
   "row": "r12",
   "left": "most of the carton of cream",
   "use": "Use the rest as a swirl on Carrot and coriander."
  },
  {
   "row": "r14",
   "left": "most of the pot of creme fraiche",
   "use": "Use the rest as a swirl on Coconut squash."
  }
 ],
 "swaps": [
  {
   "row": "r4",
   "soup": "Coconut squash",
   "swap": "Leave out the ginger; the soup works without it.",
   "saves": "a piece of ginger"
  },
  {
   "row": "r10",
   "soup": "Mushroom",
   "swap": "Leave out the thyme; the soup works without it.",
   "saves": "a bunch of thyme"
  },
  {
   "row": "r12",
   "soup": "Mushroom",
   "swap": "Use the creme fraiche already on the list instead of cream.",
   "saves": "a carton of cream"
  }
 ],
 "cook_first": {
  "soup": "Mushroom",
  "why": "It holds cream and thyme, the most delicate items on the list."
 },
 "cupboard": []
}

Build the input yourself

Load the script in node (global.window = global; require("./ladlekit.js")), then:

const K = LadleKit;
const form = K.form({ shops: "supermarket", pantry: "olive oil\nsalt",
  tubs: [{ name: "Leek and potato", serves: 4, lines: "3 leeks\n4 potatoes\n1 onion\n1 litre chicken stock\n2 tbsp chives" },
         { name: "Chicken noodle", serves: 4, lines: "4 chicken thighs\n1 onion\n2 carrots\n1.5 litres chicken stock\n1 tbsp chives" }] });
const t = K.trip(form);                            // t.rows: merged items with need, status, tags; t.fig: counts and verdict
const built = K.buildInput(form);
const wire = K.toWire(built.input);               // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes                    // the page's re-check of the reply
// K.plainList(built, reply)                      // the tick-off list, grouped by aisle

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://ladle-list.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"}
}

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":"ladle-list"}' | jq -r '.data.token')

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

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": "trip", "soups": "Tomato and red lentil - serves 4\n  - 1 onion ...", "pantry": "olive oil; salt", "shops": "supermarket", "rows": "r1 | Fruit and veg | onion | need 2 | ...", "figures": "soups 2; servings 10; items 15; to buy 12; already have 2; check in the cupboard 1; shared by two or more soups 6; pack-size items to buy 2; loose ends 0", "page_verdict": "tight", "buy_rows": "r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r14", "loose_rows": "none", "check_rows": "r13", "clip_note": "water is not on the list (1 line)"}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses ladle-list:<task>:<inputHash>:a<attempt> (the one task is trip), 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="ladle-list:trip:$(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}' 

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: ladle-list:trip:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json