← Plate Planner / API
Tokens

Drive Plate Planner from your own code

Everything the page does is available over HTTP. A natural use is a school, nursery or care-home menu kept as data: a script builds each day's plate from food ids and grams, asks for a review when a menu changes, and prints the carer's note for the day.

One thing to be clear about first: the model never does the arithmetic. The caller computes the per-person totals, the meal's reference values, the balance ring and the flags from the food table, and sends them as facts with the plate. The model returns judgement and concrete swaps as food ids with grams; the caller applies each swap to a copy of the plate and checks every number the model cites. The page's own code ships as plain scripts you can load in node — see the end of the examples. Plate Planner is a general guide to everyday eating, not medical or dietetic advice.

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":"plate-planner"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object: never wrap it as {"input": {…}} — that returns 200, warns unknown field 'input', and hides every field from the model.

Errors:

StatusCodeMeaning
400validation_errorThe body is not a JSON object.
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: reviews and notes are metered and need 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 comes first

One system prompt serves three tasks. task picks the contract; a missing or unknown task is answered as the closest one, named in the reply's task.

taskWhenExtra fields
reviewA first look at a plate for its eater and occasion.None.
rereview"I changed the plate; review again" — after accepting or declining suggestions, or editing by hand.previous and changes; the reply adds one previous entry per earlier suggestion.
noteA short note for the parent or carer who packs or serves the plate.review: the last review's gist and what was accepted or declined ({} when there was no review).

Every run body is a JSON object of string fields (the declared input schema; task, title, eater, occasion, plate, totals, balance, flags and catalog are required, and /estimate warns when one is missing). Structured fields are JSON-encoded strings.

FieldWhat it holds
task"review", "rereview" or "note".
titleThe plate's name.
eaterJSON: {"id","label","diet":"any|vegetarian|vegan|pescatarian|no-pork","avoid":["peanut",...],"daily_reference":{"kcal","protein_g","fibre_g","calcium_mg","iron_mg","vitc_mg","sodium_mg"},"note","under_five_rules","picky","small_appetite"}.
occasionJSON: {"id","label","share_of_day","servings","packed_cold","vessel":"plate|bento|board","fasting","protein_floor_g"}.
plateJSON: [{"id","name","zone":"veg|fruit|starch|protein|dairy|extras|drink","g","portion"}]. Grams are for the whole spread. Past 30 foods the heaviest go and clipped carries the rest as grams per zone.
totalsJSON: {"per_person":{metric: amount},"targets_per_person":{...},"status":{metric: "low|ok|high"},"grams","whole_spread"?}, computed by the caller. Metric keys: kcal protein_g carbs_g sugars_g fat_g satfat_g fibre_g calcium_mg iron_mg vitc_mg sodium_mg.
balanceJSON: the balance ring, {"score","label","shares_pct":{...},"dairy_on_side","rule"}.
flagsJSON: [{"key","sev":"error|warn|info","msg"}] — allergen, diet, cold, choking, low, high, balance.
catalogText: a header line and one |-separated row per table food the reviewer may suggest (then the foods on the plate), with values per 100 g, portions, allergens, diet and whether it travels cold.
notesThe person's own words about the eater (preferences, dislikes, timing). The page cuts the middle past 600 characters.
clippedOptional JSON: what was left out.
changesrereview only. JSON: [{"change":"added|removed|resized|eater|occasion|diet|avoid","id"?,"g"?,"from"?,"to"?}].
previousrereview only. JSON: {"headline","verdict","suggestions":[{"n","action","item","add","g","reason","status":"accepted|declined|open"}]}.
reviewnote only. JSON: {"headline","verdict","missing":[...],"heavy":[...],"practical":[...],"accepted":[...],"declined":[...]} or {}.
retry_noteOptional: sent once when the previous reply was not valid JSON.

The reply text is one JSON object. review and rereview:

{
  "task": "review | rereview", "headline": "...", "verdict": "works | nearly | rethink",
  "missing":     [{ "what", "metric": "fibre_g" or null, "why" }],
  "heavy":       [{ "what", "metric": "sodium_mg" or null, "why" }],
  "practical":   [{ "topic": "temperature|texture|variety|packing|appetite|timing|allergens|hydration|other", "note" }],
  "suggestions": [{ "n": 1, "action": "add|swap|resize|remove", "item": "plate id" or null, "add": { "id": "table id", "g": 80 } or null,
                    "g": null, "reason": "...", "effect": [{ "metric": "fibre_g", "delta": 1.8 }] }],
  "figures":     [{ "metric": "kcal", "value": 667 }],
  "previous":    [{ "n": 1, "status": "accepted|declined|open", "comment" }],
  "not_in_table": [{ "food", "why" }]
}

note:

{ "task": "note", "title": "...", "note": "90-160 words", "why": ["..."], "tips": ["..."], "watch": ["..."], "figures": [{ "metric", "value" }] }

A suggestion's effect is the per-person change. Apply each suggestion to a copy of the plate and recompute before trusting it; the page marks a food that is not in the table, one that breaks the eater's allergens or diet, and an effect or figure that does not match its own arithmetic.

Worked examples

These are the bundled examples on the page: the exact run body the page builds and a reply produced against the shipped prompt (the page replays the same replies for free).

review: a 6-year-old's packed lunch at a nut-free school

Input:

{
 "task": "review",
 "title": "Maya's Tuesday lunchbox",
 "eater": "(a JSON string - decoded below)",
 "occasion": "(a JSON string - decoded below)",
 "plate": "(a JSON string - decoded below)",
 "totals": "(a JSON string - decoded below)",
 "balance": "(a JSON string - decoded below)",
 "flags": "(a JSON string - decoded below)",
 "catalog": "(text, 51 rows - the first lines below)",
 "notes": "Nut-free school. She eats lunch at 12:15, the box sits in her bag from 8:00. Likes cheese, not keen on anything green."
}

eater, decoded:

{
 "id": "child",
 "label": "Child, 4-8",
 "diet": "any",
 "avoid": [
  "peanut",
  "tree-nut"
 ],
 "daily_reference": {
  "kcal": 1500,
  "protein_g": 19,
  "fibre_g": 25,
  "calcium_mg": 1000,
  "iron_mg": 10,
  "vitc_mg": 25,
  "sodium_mg": 1500
 },
 "note": "Growing; calcium and iron matter; portions are child-sized.",
 "under_five_rules": true,
 "picky": false,
 "small_appetite": false
}

occasion, decoded:

{
 "id": "packed-lunch",
 "label": "Packed lunch",
 "share_of_day": 0.3,
 "servings": 1,
 "packed_cold": true,
 "vessel": "bento",
 "fasting": false,
 "protein_floor_g": null
}

plate, decoded:

[
 {
  "id": "white-bread",
  "name": "White bread",
  "zone": "starch",
  "g": 80,
  "portion": "2 slices"
 },
 {
  "id": "ham",
  "name": "Ham slices",
  "zone": "protein",
  "g": 20,
  "portion": "1 slice"
 },
 {
  "id": "cheese-slice",
  "name": "Processed cheese slice",
  "zone": "dairy",
  "g": 20,
  "portion": "1 slice"
 },
 {
  "id": "crisps",
  "name": "Crisps (potato chips)",
  "zone": "extras",
  "g": 25,
  "portion": "standard bag"
 },
 {
  "id": "grapes",
  "name": "Grapes",
  "zone": "fruit",
  "g": 80,
  "portion": "20 grapes"
 },
 {
  "id": "chocolate-chip-cookies",
  "name": "Chocolate chip cookies",
  "zone": "extras",
  "g": 20,
  "portion": "2 small cookies"
 },
 {
  "id": "apple-juice",
  "name": "Apple juice",
  "zone": "drink",
  "g": 150,
  "portion": "glass"
 }
]

totals, decoded:

{
 "per_person": {
  "kcal": 667,
  "protein_g": 17.2,
  "carbs_g": 99.9,
  "sugars_g": 39.3,
  "fat_g": 23.3,
  "satfat_g": 6.2,
  "fibre_g": 4.6,
  "calcium_mg": 286,
  "iron_mg": 5.1,
  "vitc_mg": 10,
  "sodium_mg": 1013
 },
 "targets_per_person": {
  "kcal": 450,
  "protein_g": 5.7,
  "carbs_g": 56.3,
  "fat_g": 16.7,
  "satfat_g": 5,
  "fibre_g": 7.5,
  "calcium_mg": 300,
  "iron_mg": 3,
  "vitc_mg": 8,
  "sodium_mg": 450
 },
 "status": {
  "kcal": "high",
  "protein_g": "ok",
  "carbs_g": "high",
  "fat_g": "high",
  "satfat_g": "high",
  "fibre_g": "low",
  "calcium_mg": "ok",
  "iron_mg": "ok",
  "vitc_mg": "ok",
  "sodium_mg": "high"
 },
 "grams": 395
}

balance, decoded:

{
 "score": 74,
 "label": "nearly",
 "shares_pct": {
  "veg_and_fruit": 44,
  "veg": 0,
  "fruit": 44,
  "starch": 44,
  "protein": 11
 },
 "dairy_on_side": true,
 "rule": "half veg and fruit (more veg), a quarter starch, a quarter protein, by weight"
}

flags, decoded:

[
 {
  "key": "choke:grapes",
  "sev": "warn",
  "msg": "Grapes is a choking risk for under-fives as usually served - cut it small (grapes and cherry tomatoes lengthways) or swap it."
 },
 {
  "key": "high:kcal",
  "sev": "info",
  "msg": "Energy is high for this meal: 667 kcal against 450 kcal."
 },
 {
  "key": "high:carbs_g",
  "sev": "info",
  "msg": "Carbohydrate is high for this meal: 99.9 g against 56.3 g."
 },
 {
  "key": "high:fat_g",
  "sev": "info",
  "msg": "Fat is high for this meal: 23.3 g against 16.7 g."
 },
 {
  "key": "high:satfat_g",
  "sev": "warn",
  "msg": "Saturated fat is over the ceiling for this meal: 6.2 g against 5 g."
 },
 {
  "key": "low:fibre_g",
  "sev": "warn",
  "msg": "Fibre is low for this meal: 4.6 g of about 7.5 g."
 },
 {
  "key": "high:sodium_mg",
  "sev": "warn",
  "msg": "Sodium is over the ceiling for this meal: 1,013 mg against 450 mg."
 },
 {
  "key": "balance:fruit",
  "sev": "info",
  "msg": "More fruit than vegetables on the plate; the guideline leans the other way."
 }
]

catalog, the first rows:

id|name|zone|kcal|protein_g|carbs_g|fat_g|satfat_g|fibre_g|sodium_mg|calcium_mg|iron_mg|vitc_mg (per 100 g)|portions s/m/l g|allergens|diet|serve
mung-beans|Mung beans (moong), boiled|protein|105|7|19.2|0.4|0.1|7.6|2|27|1.4|1|60/120/180|-|vegan|cold-ok
pinto-beans|Pinto beans, boiled|protein|143|9|26.2|0.7|0.1|9|1|46|2.1|1|40/80/120|-|vegan|cold-ok
black-beans|Black beans, boiled|protein|132|8.9|23.7|0.5|0.1|8.7|1|27|2.1|0|40/80/120|-|vegan|cold-ok
bulgur|Bulgur wheat, boiled|starch|83|3.1|18.6|0.2|0|4.5|5|10|1|0|60/120/180|gluten|vegan|cold-ok
ricotta|Ricotta|dairy|150|7.5|7.3|10.2|6.4|0|110|206|0.1|0|30/60/90|milk|veg|cold-ok
...

Reply:

{
 "task": "review",
 "headline": "Maya's nut-free lunchbox is filling and easy to pack, but it leans hard on refined bread, crisps and cookies, so salt, saturated fat and energy are all over what this meal needs, there is no vegetable at all, and the whole grapes need cutting before they go in her bag.",
 "verdict": "nearly",
 "missing": [
  {
   "what": "Fibre is thin across the whole box",
   "metric": "fibre_g",
   "why": "The box gives about 4.6g of fibre against the roughly 7.5g this meal is held to, with nothing in it that's a strong fibre source."
  },
  {
   "what": "No vegetables on the plate",
   "metric": null,
   "why": "The only plant food besides bread is grapes, so vegetables are at 0% of the plate when the guide wants veg and fruit to be about half, with more veg than fruit."
  }
 ],
 "heavy": [
  {
   "what": "Sodium is well over the ceiling",
   "metric": "sodium_mg",
   "why": "Between the bread, ham, cheese slice and crisps the box reaches 1013mg against a 450mg ceiling for this meal."
  },
  {
   "what": "Saturated fat is over the ceiling",
   "metric": "satfat_g",
   "why": "The processed cheese slice is a concentrated source here, taking the plate to 6.2g against a 5g ceiling."
  },
  {
   "what": "Energy is high for one lunch",
   "metric": "kcal",
   "why": "At 667kcal this is well above the 450kcal this meal is held to."
  },
  {
   "what": "A lot of refined, sugary carbohydrate",
   "metric": "carbs_g",
   "why": "Carbs sit at 99.9g against 56.3g, mostly from bread, grapes, apple juice and the cookies rather than fibre-rich food."
  }
 ],
 "practical": [
  {
   "topic": "packing",
   "note": "The box sits in her bag from 8:00 until lunch at 12:15, so pack an ice pack against the ham and cheese slice, and keep the apple juice away from the sandwich so the bread doesn't turn soggy."
  },
  {
   "topic": "texture",
   "note": "Whole grapes are a choking risk at her age - cut each one lengthways into quarters before packing, or use the swap below instead."
  },
  {
   "topic": "variety",
   "note": "She isn't keen on anything green, so any vegetable you add is worth picking from the red or yellow end, not the broccoli-and-spinach end."
  }
 ],
 "suggestions": [
  {
   "n": 1,
   "action": "swap",
   "item": "grapes",
   "add": {
    "id": "raspberries",
    "g": 60
   },
   "g": null,
   "reason": "Swap the grapes for raspberries: still a fruit in a familiar portion, but nothing whole and round to cut up, and well over four times the fibre to help close today's gap.",
   "effect": [
    {
     "metric": "fibre_g",
     "delta": 3.2
    },
    {
     "metric": "kcal",
     "delta": -24
    }
   ]
  },
  {
   "n": 2,
   "action": "swap",
   "item": "crisps",
   "add": {
    "id": "red-pepper",
    "g": 30
   },
   "g": null,
   "reason": "Swap the crisps for red pepper cut into thin strips (not thick wedges, for easy biting): it gives the box its first vegetable - red, not green - while cutting sodium and energy hard.",
   "effect": [
    {
     "metric": "sodium_mg",
     "delta": -131
    },
    {
     "metric": "kcal",
     "delta": -125
    },
    {
     "metric": "fat_g",
     "delta": -8.4
    }
   ]
  },
  {
   "n": 3,
   "action": "swap",
   "item": "white-bread",
   "add": {
    "id": "wholemeal-bread",
    "g": 80
   },
   "g": null,
   "reason": "Swap the white bread for the same weight of wholemeal bread: still her usual two-slice sandwich, with more fibre, a touch more calcium and a little less sodium.",
   "effect": [
    {
     "metric": "fibre_g",
     "delta": 2.6
    },
    {
     "metric": "sodium_mg",
     "delta": -28
    },
    {
     "metric": "kcal",
     "delta": -11
    }
   ]
  },
  {
   "n": 4,
   "action": "resize",
   "item": "chocolate-chip-cookies",
   "add": null,
   "g": 10,
   "reason": "Keep the chocolate chip cookies as her treat, but make it one small cookie instead of two: still something sweet, with noticeably less saturated fat and sodium.",
   "effect": [
    {
     "metric": "kcal",
     "delta": -49
    },
    {
     "metric": "satfat_g",
     "delta": -0.8
    },
    {
     "metric": "sodium_mg",
     "delta": -31
    }
   ]
  }
 ],
 "figures": [
  {
   "me
... (shortened here; the page shows it whole)

review: a 16-year-old's dinner after football practice

Input:

{
 "task": "review",
 "title": "Sam after Thursday practice",
 "eater": "(a JSON string - decoded below)",
 "occasion": "(a JSON string - decoded below)",
 "plate": "(a JSON string - decoded below)",
 "totals": "(a JSON string - decoded below)",
 "balance": "(a JSON string - decoded below)",
 "flags": "(a JSON string - decoded below)",
 "catalog": "(text, 48 rows - the first lines below)",
 "notes": "Plays football 4 times a week; eats this at 7 pm after a 90-minute session."
}

eater, decoded:

{
 "id": "teen-active",
 "label": "Teenager, 14-18, active",
 "diet": "any",
 "avoid": [],
 "daily_reference": {
  "kcal": 2800,
  "protein_g": 49,
  "fibre_g": 32,
  "calcium_mg": 1300,
  "iron_mg": 13,
  "vitc_mg": 70,
  "sodium_mg": 2300
 },
 "note": "Active-range energy; iron for girls and calcium for both.",
 "under_five_rules": false,
 "picky": false,
 "small_appetite": false
}

occasion, decoded:

{
 "id": "post-training",
 "label": "Meal after training",
 "share_of_day": 0.35,
 "servings": 1,
 "packed_cold": false,
 "vessel": "plate",
 "fasting": false,
 "protein_floor_g": 25
}

plate, decoded:

[
 {
  "id": "chicken-breast",
  "name": "Chicken breast, roasted",
  "zone": "protein",
  "g": 150,
  "portion": "large serving"
 },
 {
  "id": "white-rice",
  "name": "White rice, boiled",
  "zone": "starch",
  "g": 225,
  "portion": "large serving"
 },
 {
  "id": "broccoli",
  "name": "Broccoli, boiled",
  "zone": "veg",
  "g": 80,
  "portion": "4 florets"
 },
 {
  "id": "sweetcorn",
  "name": "Sweetcorn, boiled",
  "zone": "veg",
  "g": 40,
  "portion": "2 tablespoons"
 },
 {
  "id": "semi-skimmed-milk",
  "name": "Semi-skimmed milk (2%)",
  "zone": "dairy",
  "g": 250,
  "portion": "large glass"
 }
]

totals, decoded:

{
 "per_person": {
  "kcal": 725,
  "protein_g": 63.8,
  "carbs_g": 88.9,
  "sugars_g": 15.3,
  "fat_g": 11.7,
  "satfat_g": 5.1,
  "fibre_g": 4.5,
  "calcium_mg": 378,
  "iron_mg": 5,
  "vitc_mg": 54,
  "sodium_mg": 264
 },
 "targets_per_person": {
  "kcal": 980,
  "protein_g": 25,
  "carbs_g": 122.5,
  "fat_g": 36.3,
  "satfat_g": 10.9,
  "fibre_g": 11.2,
  "calcium_mg": 455,
  "iron_mg": 4.6,
  "vitc_mg": 25,
  "sodium_mg": 805
 },
 "status": {
  "kcal": "ok",
  "protein_g": "ok",
  "carbs_g": "ok",
  "fat_g": "low",
  "satfat_g": "ok",
  "fibre_g": "low",
  "calcium_mg": "ok",
  "iron_mg": "ok",
  "vitc_mg": "ok",
  "sodium_mg": "ok"
 },
 "grams": 745
}

balance, decoded:

{
 "score": 66,
 "label": "nearly",
 "shares_pct": {
  "veg_and_fruit": 24,
  "veg": 24,
  "fruit": 0,
  "starch": 45,
  "protein": 30
 },
 "dairy_on_side": true,
 "rule": "half veg and fruit (more veg), a quarter starch, a quarter protein, by weight"
}

flags, decoded:

[
 {
  "key": "low:fat_g",
  "sev": "warn",
  "msg": "Fat is low for this meal: 11.7 g of about 36.3 g."
 },
 {
  "key": "low:fibre_g",
  "sev": "warn",
  "msg": "Fibre is low for this meal: 4.5 g of about 11.2 g."
 },
 {
  "key": "drink:none",
  "sev": "info",
  "msg": "No drink on the plate."
 }
]

catalog, the first rows:

id|name|zone|kcal|protein_g|carbs_g|fat_g|satfat_g|fibre_g|sodium_mg|calcium_mg|iron_mg|vitc_mg (per 100 g)|portions s/m/l g|allergens|diet|serve
split-peas|Split peas (dal-style), boiled|protein|118|8.3|21.1|0.4|0.1|8.3|2|14|1.3|0|60/120/180|-|vegan|hot
mung-beans|Mung beans (moong), boiled|protein|105|7|19.2|0.4|0.1|7.6|2|27|1.4|1|60/120/180|-|vegan|cold-ok
pear|Pear|fruit|57|0.4|15.2|0.1|0|3.1|1|9|0.2|4|100/150/200|-|vegan|cold-ok
spinach-cooked|Spinach, boiled|veg|23|3|3.8|0.3|0|2.4|70|136|3.6|10|40/80/120|-|vegan|hot
yellow-pepper|Yellow pepper, raw|veg|27|1|6.3|0.2|0|0.9|2|11|0.5|184|30/60/120|-|vegan|cold-ok
...

Reply:

{
 "task": "review",
 "headline": "This plate gives Sam plenty of energy and protein to refuel after training, with good calcium from the milk. It's just light on fibre and fat, and there's no fruit, so it's nearly there rather than spot on.",
 "verdict": "nearly",
 "missing": [
  {
   "what": "Fibre",
   "metric": "fibre_g",
   "why": "Only 4.5 g against about 11.2 g for this meal — the rice is refined and there's no fruit or extra veg bringing fibre."
  },
  {
   "what": "Fat",
   "metric": "fat_g",
   "why": "11.7 g against about 36.3 g — the chicken is lean and the milk is semi-skimmed, so there's little fat anywhere on the plate."
  },
  {
   "what": "Fruit",
   "metric": null,
   "why": "The balance ring shows 0% fruit; a piece of fruit would help meet the 'half veg and fruit' guide and add fibre and vitamin C."
  }
 ],
 "heavy": [
  {
   "what": "White rice portion",
   "metric": null,
   "why": "Rice alone is 45% of the core plate's weight, nearly double the quarter-starch guide, which is squeezing out veg and fruit."
  }
 ],
 "practical": [
  {
   "topic": "timing",
   "note": "Eating at 7 pm, a couple of hours after a 90-minute session, is a sensible window for this kind of refuelling plate."
  },
  {
   "topic": "hydration",
   "note": "The milk gives some fluid, but a glass of water alongside would help finish rehydrating after a hard session."
  },
  {
   "topic": "variety",
   "note": "Broccoli and sweetcorn are both boiled sides; adding something raw or fresh, like fruit, would bring some contrast in taste and texture."
  }
 ],
 "suggestions": [
  {
   "n": 1,
   "action": "add",
   "item": null,
   "add": {
    "id": "kale-cooked",
    "g": 80
   },
   "g": null,
   "reason": "A side of kale adds real fibre and calcium without piling on more rice, and it's a hot vegetable so fits straight onto a warm plate tonight.",
   "effect": [
    {
     "metric": "fibre_g",
     "delta": 3.2
    },
    {
     "metric": "calcium_mg",
     "delta": 120
    },
    {
     "metric": "kcal",
     "delta": 29
    }
   ]
  },
  {
   "n": 2,
   "action": "add",
   "item": null,
   "add": {
    "id": "cottage-cheese",
    "g": 80
   },
   "g": null,
   "reason": "A small pot of cottage cheese brings the fat and extra protein this plate is short on, alongside the chicken, with sodium still comfortably under the ceiling (264 mg now, nowhere near 805 mg).",
   "effect": [
    {
     "metric": "fat_g",
     "delta": 3.4
    },
    {
     "metric": "protein_g",
     "delta": 8.9
    },
    {
     "metric": "kcal",
     "delta": 78
    }
   ]
  },
  {
   "n": 3,
   "action": "add",
   "item": null,
   "add": {
    "id": "kiwi",
    "g": 75
   },
   "g": null,
   "reason": "A kiwi gives the plate its first piece of fruit, with more fibre and vitamin C, and is easy to eat with the fingers at the table.",
   "effect": [
    {
     "metric": "fibre_g",
     "delta": 2.3
    },
    {
     "metric": "vitc_mg",
     "delta": 70
    },
    {
     "metric": "kcal",
     "delta": 46
    }
   ]
  }
 ],
 "figures": [
  {
   "metric": "kcal",
   "value": 725
  },
  {
   "metric": "protein_g",
   "value": 63.8
  },
  {
   "metric": "fat_g",
   "value": 11.7
  },
  {
   "metric": "fibre_g",
   "value": 4.5
  },
  {
   "metric": "sodium_mg",
   "value": 264
  }
 ],
 "previous": [],
 "not_in_table": []
}

rereview: the lunchbox after most suggestions were accepted and one declined

Input (previous lists each earlier suggestion with what the person did, changes what changed on the plate):

{
 "task": "rereview",
 "title": "Maya's Tuesday lunchbox",
 "eater": "(a JSON string - decoded below)",
 "occasion": "(a JSON string - decoded below)",
 "plate": "(a JSON string - decoded below)",
 "totals": "(a JSON string - decoded below)",
 "balance": "(a JSON string - decoded below)",
 "flags": "(a JSON string - decoded below)",
 "catalog": "(text, 51 rows - the first lines below)",
 "notes": "Nut-free school. She eats lunch at 12:15, the box sits in her bag from 8:00. Likes cheese, not keen on anything green.",
 "changes": "(a JSON string - decoded below)",
 "previous": "(a JSON string - decoded below)"
}

eater, decoded:

{
 "id": "child",
 "label": "Child, 4-8",
 "diet": "any",
 "avoid": [
  "peanut",
  "tree-nut"
 ],
 "daily_reference": {
  "kcal": 1500,
  "protein_g": 19,
  "fibre_g": 25,
  "calcium_mg": 1000,
  "iron_mg": 10,
  "vitc_mg": 25,
  "sodium_mg": 1500
 },
 "note": "Growing; calcium and iron matter; portions are child-sized.",
 "under_five_rules": true,
 "picky": false,
 "small_appetite": false
}

occasion, decoded:

{
 "id": "packed-lunch",
 "label": "Packed lunch",
 "share_of_day": 0.3,
 "servings": 1,
 "packed_cold": true,
 "vessel": "bento",
 "fasting": false,
 "protein_floor_g": null
}

plate, decoded:

[
 {
  "id": "ham",
  "name": "Ham slices",
  "zone": "protein",
  "g": 20,
  "portion": "1 slice"
 },
 {
  "id": "cheese-slice",
  "name": "Processed cheese slice",
  "zone": "dairy",
  "g": 20,
  "portion": "1 slice"
 },
 {
  "id": "chocolate-chip-cookies",
  "name": "Chocolate chip cookies",
  "zone": "extras",
  "g": 20,
  "portion": "2 small cookies"
 },
 {
  "id": "apple-juice",
  "name": "Apple juice",
  "zone": "drink",
  "g": 150,
  "portion": "glass"
 },
 {
  "id": "raspberries",
  "name": "Raspberries",
  "zone": "fruit",
  "g": 60,
  "portion": "handful"
 },
 {
  "id": "red-pepper",
  "name": "Red pepper, raw",
  "zone": "veg",
  "g": 30,
  "portion": "a few strips"
 },
 {
  "id": "wholemeal-bread",
  "name": "Wholemeal bread",
  "zone": "starch",
  "g": 80,
  "portion": "2 slices"
 }
]

totals, decoded:

{
 "per_person": {
  "kcal": 507,
  "protein_g": 18.9,
  "carbs_g": 75.6,
  "sugars_g": 29.6,
  "fat_g": 15.3,
  "satfat_g": 5.3,
  "fibre_g": 10.3,
  "calcium_mg": 303,
  "iron_mg": 4.1,
  "vitc_mg": 56,
  "sodium_mg": 854
 },
 "targets_per_person": {
  "kcal": 450,
  "protein_g": 5.7,
  "carbs_g": 56.3,
  "fat_g": 16.7,
  "satfat_g": 5,
  "fibre_g": 7.5,
  "calcium_mg": 300,
  "iron_mg": 3,
  "vitc_mg": 8,
  "sodium_mg": 450
 },
 "status": {
  "kcal": "ok",
  "protein_g": "ok",
  "carbs_g": "high",
  "fat_g": "ok",
  "satfat_g": "high",
  "fibre_g": "ok",
  "calcium_mg": "ok",
  "iron_mg": "ok",
  "vitc_mg": "ok",
  "sodium_mg": "high"
 },
 "grams": 380
}

balance, decoded:

{
 "score": 77,
 "label": "nearly",
 "shares_pct": {
  "veg_and_fruit": 47,
  "veg": 16,
  "fruit": 32,
  "starch": 42,
  "protein": 11
 },
 "dairy_on_side": true,
 "rule": "half veg and fruit (more veg), a quarter starch, a quarter protein, by weight"
}

flags, decoded:

[
 {
  "key": "high:carbs_g",
  "sev": "info",
  "msg": "Carbohydrate is high for this meal: 75.6 g against 56.3 g."
 },
 {
  "key": "high:satfat_g",
  "sev": "warn",
  "msg": "Saturated fat is over the ceiling for this meal: 5.3 g against 5 g."
 },
 {
  "key": "high:sodium_mg",
  "sev": "warn",
  "msg": "Sodium is over the ceiling for this meal: 854 mg against 450 mg."
 },
 {
  "key": "balance:fruit",
  "sev": "info",
  "msg": "More fruit than vegetables on the plate; the guideline leans the other way."
 }
]

changes, decoded:

[
 {
  "change": "added",
  "id": "raspberries",
  "g": 60
 },
 {
  "change": "added",
  "id": "red-pepper",
  "g": 30
 },
 {
  "change": "added",
  "id": "wholemeal-bread",
  "g": 80
 },
 {
  "change": "removed",
  "id": "white-bread",
  "from": 80
 },
 {
  "change": "removed",
  "id": "crisps",
  "from": 25
 },
 {
  "change": "removed",
  "id": "grapes",
  "from": 80
 }
]

previous, decoded:

{
 "headline": "Maya's nut-free lunchbox is filling and easy to pack, but it leans hard on refined bread, crisps and cookies, so salt, saturated fat and energy are all over what this meal needs, there is no vegetable at all, and the whole grapes need cutting before they go in her bag.",
 "verdict": "nearly",
 "suggestions": [
  {
   "n": 1,
   "action": "swap",
   "item": "grapes",
   "add": {
    "id": "raspberries",
    "g": 60
   },
   "g": null,
   "reason": "Swap the grapes for raspberries: still a fruit in a familiar portion, but nothing whole and round to cut up, and well over four times the fibre to help close to",
   "status": "accepted"
  },
  {
   "n": 2,
   "action": "swap",
   "item": "crisps",
   "add": {
    "id": "red-pepper",
    "g": 30
   },
   "g": null,
   "reason": "Swap the crisps for red pepper cut into thin strips (not thick wedges, for easy biting): it gives the box its first vegetable - red, not green - while cutting s",
   "status": "accepted"
  },
  {
   "n": 3,
   "action": "swap",
   "item": "white-bread",
   "add": {
    "id": "wholemeal-bread",
    "g": 80
   },
   "g": null,
   "reason": "Swap the white bread for the same weight of wholemeal bread: still her usual two-slice sandwich, with more fibre, a touch more calcium and a little less sodium.",
   "status": "accepted"
  },
  {
   "n": 4,
   "action": "resize",
   "item": "chocolate-chip-cookies",
   "add": null,
   "g": 10,
   "reason": "Keep the chocolate chip cookies as her treat, but make it one small cookie instead of two: still something sweet, with noticeably less saturated fat and sodium.",
   "status": "declined"
  }
 ]
}

catalog, the first rows:

id|name|zone|kcal|protein_g|carbs_g|fat_g|satfat_g|fibre_g|sodium_mg|calcium_mg|iron_mg|vitc_mg (per 100 g)|portions s/m/l g|allergens|diet|serve
ricotta|Ricotta|dairy|150|7.5|7.3|10.2|6.4|0|110|206|0.1|0|30/60/90|milk|veg|cold-ok
white-rice|White rice, boiled|starch|130|2.7|28.2|0.3|0.1|0.4|1|10|1.2|0|75/150/225|-|vegan|cold-ok
yellow-pepper|Yellow pepper, raw|veg|27|1|6.3|0.2|0|0.9|2|11|0.5|184|30/60/120|-|vegan|cold-ok
baby-spinach|Baby spinach, raw|veg|23|2.9|3.6|0.4|0.1|2.2|79|99|2.7|28|15/30/60|-|vegan|cold-ok
broccoli-raw|Broccoli florets, raw|veg|34|2.8|6.6|0.4|0.1|2.6|33|47|0.7|89|30/60/90|-|vegan|cold-ok
...

Reply:

{
 "task": "rereview",
 "headline": "All three accepted swaps are doing their job: raspberries and red pepper brought in real fruit and veg with nothing round left to cut, and the wholemeal bread adds a little fibre over the white loaf. Saturated fat and sodium are still over what this meal needs, mostly from the cheese slice, the ham and the bread, carbohydrate is still a bit high, and the box still leans more fruit than vegetable.",
 "verdict": "nearly",
 "missing": [
  {
   "what": "more vegetable than fruit",
   "metric": null,
   "why": "Vegetables are still a smaller share of the plate than fruit by weight, the opposite of what the balance guide wants, even with the red pepper now in."
  },
  {
   "what": "more protein by weight",
   "metric": null,
   "why": "The protein part of the box is the lightest by weight, even though the plate already carries plenty of protein for a meal this size."
  }
 ],
 "heavy": [
  {
   "what": "Sodium",
   "metric": "sodium_mg",
   "why": "Sodium is still well over this meal's ceiling, 854 mg against 450 mg, mostly from the cheese slice, the ham and the bread."
  },
  {
   "what": "Saturated fat",
   "metric": "satfat_g",
   "why": "Saturated fat is still over the ceiling too, 5.3 g against 5 g, mainly from the cheese slice and the cookies."
  },
  {
   "what": "Carbohydrate",
   "metric": "carbs_g",
   "why": "Carbohydrate is still higher than this meal needs, 75.6 g against 56.3 g, with the bread, juice and cookies all adding to it."
  }
 ],
 "practical": [
  {
   "topic": "safety",
   "note": "The box sits in her bag for more than four hours before lunch, so pack an ice pack or a small frozen drink next to the ham and cheese to keep them properly cold until 12:15."
  },
  {
   "topic": "packing",
   "note": "Keep the apple juice in a leak-proof cup away from the bread, so the sandwich doesn't turn soggy by lunchtime."
  }
 ],
 "suggestions": [
  {
   "n": 1,
   "action": "swap",
   "item": "ham",
   "add": {
    "id": "tuna-canned",
    "g": 60
   },
   "g": null,
   "reason": "Swap the ham slices for a bit more tuna (canned in water): tuna carries less sodium than ham and gives Maya noticeably more protein, the lightest part of the box by weight right now.",
   "effect": [
    {
     "metric": "protein_g",
     "delta": 8.3
    },
    {
     "metric": "sodium_mg",
     "delta": -15
    },
    {
     "metric": "kcal",
     "delta": 19
    }
   ]
  },
  {
   "n": 2,
   "action": "resize",
   "item": "apple-juice",
   "add": null,
   "g": 100,
   "reason": "Pour a smaller glass of apple juice, 100 g instead of 150 g: it trims some of the carbohydrate and sugar in one go, since she already has raspberries for fruit.",
   "effect": [
    {
     "metric": "carbs_g",
     "delta": -5.7
    },
    {
     "metric": "kcal",
     "delta": -23
    },
    {
     "metric": "sodium_mg",
     "delta": -2
    }
   ]
  },
  {
   "n": 3,
   "action": "add",
   "item": null,
   "add": {
    "id": "yellow-pepper",
    "g": 30
   },
   "g": null,
   "reason": "Add a small handful of yellow pepper strips (30 g) next to the red pepper, cut thin the same way for easy biting: another vegetable that isn't green, so the box has more vegetable than fruit by weight.",
   "effect": [
    {
     "metric": "fibre_g",
     "delta": 0.3
    },
    {
     "metric": "vitc_mg",
     "delta": 55
    },
    {
     "metric": "kcal",
     "delta": 8
    }
   ]
  }
 ],
 "figures": [
  {
   "metric": "sodium_mg",
   "value": 854
  },
  {
   "metric": "satfat_g",
   "value": 5.3
  },
  {
   "metric": "carbs_g",
   "value": 75.6
  }
 ],
 "previous": [
  {
   "n": 1,
   "status": "accepted",
   "comment": "Raspberries are in and there's nothing whole and round left to cut before the bag goes in, with extra fibre and vitamin C as a bonus."
  },
  {
   "n": 2,
   "status": "accepted",
   "comment": "The red pepper strips gave the box its first vegetable and came in cut thin for easy, safe biting."
  },
  {
   "n": 3,
   "status": "accepted",
   "comment": "Wholemeal bread is in place of the white loaf, for a little more fibre, but sodium is still high overall since bread, cheese and ham all add to it."
  },
  {
   "n": 4,
   "st
... (shortened here; the page shows it whole)

note: the carer's note for the older adult's small lunch, drawing on its review

Input (review is the handoff from the review task):

{
 "task": "note",
 "title": "Grandad's Sunday lunch",
 "eater": "(a JSON string - decoded below)",
 "occasion": "(a JSON string - decoded below)",
 "plate": "(a JSON string - decoded below)",
 "totals": "(a JSON string - decoded below)",
 "balance": "(a JSON string - decoded below)",
 "flags": "(a JSON string - decoded below)",
 "catalog": "(text, 49 rows - the first lines below)",
 "notes": "Lives alone, appetite has dropped, finds chewing meat tiring. Likes soup.",
 "review": "(a JSON string - decoded below)"
}

eater, decoded:

{
 "id": "older",
 "label": "Older adult, 70+, small appetite",
 "diet": "any",
 "avoid": [],
 "daily_reference": {
  "kcal": 1800,
  "protein_g": 60,
  "fibre_g": 25,
  "calcium_mg": 1200,
  "iron_mg": 8,
  "vitc_mg": 83,
  "sodium_mg": 2300
 },
 "note": "Protein and energy in small, soft, easy portions matter more than volume.",
 "under_five_rules": false,
 "picky": false,
 "small_appetite": true
}

occasion, decoded:

{
 "id": "lunch",
 "label": "Lunch at home",
 "share_of_day": 0.3,
 "servings": 1,
 "packed_cold": false,
 "vessel": "plate",
 "fasting": false,
 "protein_floor_g": null
}

plate, decoded:

[
 {
  "id": "tomato-soup",
  "name": "Tomato soup",
  "zone": "veg",
  "g": 250,
  "portion": "medium bowl"
 },
 {
  "id": "white-bread",
  "name": "White bread",
  "zone": "starch",
  "g": 25,
  "portion": "1 thin slice"
 },
 {
  "id": "butter",
  "name": "Butter",
  "zone": "extras",
  "g": 5,
  "portion": "thin spread"
 },
 {
  "id": "tea",
  "name": "Tea, black, no milk",
  "zone": "drink",
  "g": 250,
  "portion": "mug"
 }
]

totals, decoded:

{
 "per_person": {
  "kcal": 185,
  "protein_g": 4,
  "carbs_g": 31.9,
  "sugars_g": 11.4,
  "fat_g": 5.4,
  "satfat_g": 3,
  "fibre_g": 1.9,
  "calcium_mg": 57,
  "iron_mg": 1.7,
  "vitc_mg": 15,
  "sodium_mg": 627
 },
 "targets_per_person": {
  "kcal": 540,
  "protein_g": 18,
  "carbs_g": 67.5,
  "fat_g": 20,
  "satfat_g": 6,
  "fibre_g": 7.5,
  "calcium_mg": 360,
  "iron_mg": 2.4,
  "vitc_mg": 25,
  "sodium_mg": 690
 },
 "status": {
  "kcal": "low",
  "protein_g": "low",
  "carbs_g": "low",
  "fat_g": "low",
  "satfat_g": "ok",
  "fibre_g": "low",
  "calcium_mg": "low",
  "iron_mg": "low",
  "vitc_mg": "low",
  "sodium_mg": "ok"
 },
 "grams": 530
}

balance, decoded:

{
 "score": 45,
 "label": "lopsided",
 "shares_pct": {
  "veg_and_fruit": 91,
  "veg": 91,
  "fruit": 0,
  "starch": 9,
  "protein": 0
 },
 "dairy_on_side": false,
 "rule": "half veg and fruit (more veg), a quarter starch, a quarter protein, by weight"
}

flags, decoded:

[
 {
  "key": "low:kcal",
  "sev": "warn",
  "msg": "Energy is low for this meal: 185 kcal of about 540 kcal."
 },
 {
  "key": "low:protein_g",
  "sev": "warn",
  "msg": "Protein is low for this meal: 4 g of about 18 g."
 },
 {
  "key": "low:carbs_g",
  "sev": "warn",
  "msg": "Carbohydrate is low for this meal: 31.9 g of about 67.5 g."
 },
 {
  "key": "low:fat_g",
  "sev": "warn",
  "msg": "Fat is low for this meal: 5.4 g of about 20 g."
 },
 {
  "key": "low:fibre_g",
  "sev": "warn",
  "msg": "Fibre is low for this meal: 1.9 g of about 7.5 g."
 },
 {
  "key": "low:calcium_mg",
  "sev": "warn",
  "msg": "Calcium is low for this meal: 57 mg of about 360 mg."
 },
 {
  "key": "low:iron_mg",
  "sev": "warn",
  "msg": "Iron is low for this meal: 1.7 mg of about 2.4 mg."
 },
 {
  "key": "low:vitc_mg",
  "sev": "warn",
  "msg": "Vitamin C is low for this meal: 15 mg of about 25 mg."
 },
 {
  "key": "balance:lopsided",
  "sev": "warn",
  "msg": "The plate is lopsided (balance 45/100): 91% veg and fruit, 9% starch, 0% protein by weight."
 }
]

review, decoded:

{
 "headline": "This lunch at home is soft and simple — tomato soup, a little toast, and tea — which suits someone who finds chewing meat tiring, but as it stands it's a starter-sized plate with almost no protein, calcium or vitamin C for a full lunch.",
 "verdict": "rethink",
 "missing": [
  "No protein food on the plate",
  "Overall it's a light lunch",
  "No fruit or extra veg",
  "No dairy or other calcium source"
 ],
 "heavy": [],
 "practical": [
  "texture: The soup and thin toast are already soft and manageable, which matters since chewing meat tires him; anything added should be just as soft.",
  "appetite: 185 kcal is closer to a starter than a lunch for someone eating little, so additions should be concentrated, not bulky, to fit his smaller volume.",
  "variety: The balance ring is 91% veg and soup and 0% protein, so even one small soft protein addition would shift it a long way.",
  "other: Sodium is already near its ceiling for this meal (627 mg of about 690 mg) from the soup and bread, so a cured or canned salty protein like tinned fish isn't the best next step; a plain soft food such as yogurt or lentils fits better without pushing salt higher."
 ],
 "accepted": [],
 "declined": []
}

catalog, the first rows:

id|name|zone|kcal|protein_g|carbs_g|fat_g|satfat_g|fibre_g|sodium_mg|calcium_mg|iron_mg|vitc_mg (per 100 g)|portions s/m/l g|allergens|diet|serve
sirloin-steak|Sirloin steak, grilled|protein|183|30.6|0|5.8|2.2|0|64|20|2|0|80/140/200|-|meat|cold-ok
chicken-breast|Chicken breast, roasted|protein|165|31|0|3.6|1|0|74|15|1|0|60/100/150|-|meat|cold-ok
turkey-breast|Turkey breast, roasted|protein|147|30.1|0|2.1|0.6|0|99|9|0.7|0|60/100/150|-|meat|cold-ok
split-peas|Split peas (dal-style), boiled|protein|118|8.3|21.1|0.4|0.1|8.3|2|14|1.3|0|60/120/180|-|vegan|hot
mung-beans|Mung beans (moong), boiled|protein|105|7|19.2|0.4|0.1|7.6|2|27|1.4|1|60/120/180|-|vegan|cold-ok
...

Reply:

{
 "task": "note",
 "title": "Grandad's tomato soup lunch",
 "note": "You've kept this lunch gentle for Grandad: tomato soup, a thin slice of bread and butter, and a mug of tea. Nothing here needs much chewing, which matters now that chewing meat tires him, and he genuinely likes soup, so it's an easy, comforting plate for him to eat alone at home. As it stands, though, it's a light one rather than a full lunch. There's only 4g of protein in the whole bowl, calcium is just 57mg, and there's barely any fruit, iron or vitamin C either - the soup and bread don't carry much of those on their own. The good news is you don't need a bigger plate, just a few small, soft additions alongside what's already working well.",
 "why": [
  "The tomato soup is warm, soft and needs no chewing, and it's something Grandad genuinely enjoys.",
  "The bread and butter add a little familiar carbohydrate and energy alongside the soup.",
  "The tea gives him fluid and a comforting, familiar part of the meal.",
  "Together they make a plate he can manage easily alone, with nothing that takes much effort to eat."
 ],
 "tips": [
  "Serve the soup warm rather than piping hot, so he can eat it slowly without burning his mouth.",
  "Let him dip the bread into the soup if chewing feels tiring - it softens straight away.",
  "Pour the tea once he's started the soup, so he doesn't fill up on fluid before he's eaten."
 ],
 "watch": [
  "This lunch is very short on protein and calcium - a spoonful of plain yogurt or some soft boiled lentils stirred into the soup would help, without making the bowl any bigger.",
  "There's no fruit on the plate today - a few orange segments or some soft berries afterwards would add a bit of vitamin C."
 ],
 "figures": [
  {
   "metric": "protein_g",
   "value": 4
  },
  {
   "metric": "calcium_mg",
   "value": 57
  }
 ]
}

The page does the arithmetic and the checking; the API does not

Over the API you get the reply as JSON. The totals are yours to compute, and the page's own code is plain scripts you can load in node after global.window = {}: foods.js (the 319-food table, values per 100 g from USDA FoodData Central SR Legacy), platekit.js (PlateKit.compute(plate) for the per-person totals, targets, balance ring and flags, PlateKit.buildInput(computed, {task}) for this exact run body, PlateKit.diff(before, after) for a second look's changes) and review.js (Review.normalize(reply, task), Review.reconcile(result, computed) to apply every suggestion to a copy of the plate and check every figure and number, Review.compact(result, statuses) for the next previous and Review.forNote(result, statuses) for the note's review).

Step 1 — a tiny client helper

One function that sends JSON and unwraps the envelope.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here"   # from https://plate-planner.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":"plate-planner"}' | 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 (not wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model reads gpt-5.6-terra and model_alias gpt-terra.

# input.json: the run input object - {"task": "review", "title": "...", "eater": "{...}", "occasion": "{...}", "plate": "[...]", "totals": "{...}", "balance": "{...}", "flags": "[...]", "catalog": "...", "notes": "..."}
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 task and the input: a retry with the same key never bills twice. The page uses plate-planner:<task>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="plate-planner:review:$(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: plate-planner:review:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json