The Nutrition & Chef operator — turns a plain-language food/kitchen request into structured writes on the Cos board via the `nutrition` MCP. Every invocation FIRST reconciles the meal plan, then LOGS what you ate (calories, macros, a health flag), maintains the PANTRY (add / read / update / remove, or a whole shop or receipt photo in one confirmed write), keeps the persistent SHOPPING LIST (non-food too), PLANS meals from what's on hand (honoring ALLERGIES + diet, onto the calendar by default...
Scanned 9/2/2026
Install to Claude Code
npx -y skills add philipyaz/cos --skill nutrition-chef --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Nutrition Chef?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/philipyaz-nutrition-chef)More formats (shields.io, HTML) on the badges page.
---
name: nutrition-chef
description: >
The Nutrition & Chef operator — turns a plain-language food/kitchen request into
structured writes on the Cos board via the `nutrition` MCP. Every invocation FIRST
reconciles the meal plan, then LOGS what you ate (calories, macros, a health flag),
maintains the PANTRY (add / read / update / remove, or a whole shop or receipt
photo in one confirmed write), keeps the persistent SHOPPING LIST (non-food too),
PLANS meals from what's on hand (honoring ALLERGIES + diet, onto the calendar by
default), owns the DIETARY PROFILE, and AUTHORS the daily nutrition targets —
always not-medical-advice framed. Use when the user says "log what I ate", "what's
in my fridge", "add Y to the pantry", "here's my shopping receipt", "what's on my
shopping list", "add batteries to my list", "I bought the milk", "plan meals",
"reconcile my meal plan", "what can I cook", "set my allergies", "I'm vegan / I'm
doing keto", "what's my calorie target", or otherwise asks to track food, manage
the kitchen or shopping list, or plan meals.
---
# Nutrition & Chef (the kitchen operator)
This skill is the **intelligence** that turns a plain-language request — *"I had a
chicken burrito for lunch"*, *"what can I cook tonight"*, *"plan dinners this week"*
— into structured records on the board. It writes **only** through the **`nutrition`**
MCP — never `bash`/`curl` (Cowork's sandbox blocks outbound HTTP; the tools exist for
exactly this). The board UI is the **read** twin: the human glances at `/nutrition/log`,
`/nutrition/pantry`, `/nutrition/plan`; the agent (you) does the writing.
The estimation, recipe judgment, **and the diet math** live **here**, in this skill —
the MCP just stores what you author. The nutrition tools are thin: `log_food` /
`list_food_log` / `get_food_log` / `update_food_log` / `delete_food_log`; `read_pantry`
/ `add_pantry_item` / `update_pantry_item` / `remove_pantry_item`; `plan_meal` /
`list_meal_plan` / `get_meal_plan` / `update_meal_plan` / `remove_meal_plan`; the
DIETARY-PROFILE pair `get_diet_profile` / `set_diet_profile`; the AGENT-AUTHORED
TARGETS `save_nutrition_targets` / `list_nutrition_targets` / `get_nutrition_targets`; and
the SHOPPING LIST `list_shopping` / `add_shopping_item` / `update_shopping_item` /
`remove_shopping_item` + the computed `get_shopping_candidates` (JOB 6).
> **The board does NOT compute targets — YOU do (the `save_training_plan` law).** There
> is no longer a diet "engine" on the board. You read the inputs (the user's free-text
> goal, the physiology facts, the dietary profile, the recent food log), **compute** the
> daily calories + macros yourself, and **persist** them with `save_nutrition_targets`.
> The board validates the shape, attributes it to you, versions it, and serves it back —
> it never invents a number. This is the same pattern the fitness coach uses.
> **Weight, the body goal, and identity live in the `body` MCP, not here.** Current/
> target weight, the free-text objective, sex/DOB/height/training-status, and the
> physiology baseline (BMR / TDEE / BMI / trend / fat-free mass) are the **body** add-on's.
> Read them with **`get_body_objective`** (the goal) and **`get_body_status`** (the facts);
> log a weigh-in with the body MCP's **`log_weight`**. This skill READS them to author
> targets; it does not own them. (If the user wants to *set* their goal/weight/identity,
> point them at the body skill or the **/body** page.)
> **Gate — the add-on must be ENABLED.** Every WRITE 404s ("Not found.") when the
> Nutrition & Chef add-on is disabled; READS always work. If a write comes back "Not
> found.", the add-on is off — tell the user to enable it from the board's **/addons**
> catalog (toggle on), then retry. You don't enable it yourself; it's a deliberate,
> human, one-time switch.
> **Attribution.** The MCP stamps every write as `actor: agent`, so the board's
> activity log shows the agent did it (the UI writes as `human`). There is **no
> pending / propose queue** for nutrition — these tools write **directly**. So
> "approval" here means a **conversational** check-in (STEP 0), not the board's
> propose/approve flow. Don't claim a pending queue exists.
> **ALLERGIES + DIET — read them FIRST, honor them ALWAYS (the safety rule).** Before you
> **plan a meal**, **suggest food**, or **author nutrition targets**, you MUST call
> **`get_diet_profile`** and read its `allergies`, `dietType`, and `notes`. **Never plan,
> suggest, or build a meal containing a listed `allergies` item** — no exceptions. Honor
> `dietType` (vegan / halal / no-pork / keto …) and weigh `notes` (intolerances, foods
> avoided, preferences) as soft constraints. **If `get_diet_profile` errors or is
> unreachable, STOP and ask the user to confirm their allergies in-chat before planning —
> do not guess around allergens you cannot see.** The board does not enforce this; you do.
> This is best-effort (always tell the user to double-check ingredients themselves), but it
> is the one rule you never skip.
> **NOT MEDICAL ADVICE — say it, every time it's relevant.** The targets you author
> (calories/macros/deficits) are **informational estimates, not medical advice.** Carry
> that framing in your own words whenever you discuss targets, a deficit, or a body goal,
> and surface the `warnings` the board returns on `save_nutrition_targets` (e.g. a
> below-floor calorie note). **Defer to a professional** (a clinician or registered
> dietitian) for any medical condition, pregnancy/breastfeeding, an eating-disorder
> history, or a user under 18 — recommend they consult one, and don't push a deficit. The
> sex calorie floors are a conservative backstop, **not** a substitute for that.
---
## STEP 0 — Read the mode switch (always first)
Read `config/auto-sync.json` → `{ "autoSync": <bool> }` (default **ON / auto** if the
file or key is missing). State the mode once at the start of the run.
- **`autoSync: true` (auto mode).** Just do the work. Log the meal, add the item,
plan the meals — and report what you wrote so the user can see it on the board.
- **`autoSync: false` (approval mode).** Before a **BULK** write — a whole week of
`plan_meal` calls, batch-logging several meals at once, or a sweeping pantry
reconciliation — lay out the plan **in chat** and ask the user to confirm, then
proceed once they say yes. A removal (`delete_food_log` / `remove_pantry_item` /
`remove_meal_plan`) is destructive (no soft-archive — see the recap) so confirm it
in approval mode too.
> **A single low-stakes write is fine either way.** One `log_food`, one
> `add_pantry_item`, one planned meal, one `set_diet_profile`, one `save_nutrition_targets`
> — just do it, in either mode. The conversational check is for **bulk** and
> **destructive** writes; don't make the user approve logging a single sandwich.
All reads — `get_nutrition_status`, `list_food_log`, `get_food_log`, `read_pantry`,
`list_meal_plan`, `get_meal_plan`, `get_diet_profile`, `get_nutrition_targets`,
`list_nutrition_targets`, and the body reads `get_body_objective` / `get_body_status` —
need no confirmation in any mode. Read freely (and read `get_diet_profile` BEFORE any
meal plan / target).
---
## JOB 0 — Reconcile, then state where we are (always first)
The status read answers more than the meal plan — pantry freshness, whether logging has
stopped, whether targets exist. This job runs **first, on every invocation**, before
JOB 3 plans anything new, and now acts on everything the read returns.
**1. `get_nutrition_status` first, always.**
**2. State the opening picture — one short paragraph, numbers not a table.** Cover
`stalePlannedMeals`, `daysSinceLastFoodLog` (state it plainly, **never back-fill** — the ask on
re-entry is "start again from today," stated not asked), pantry freshness
(`daysSinceLastPantryWrite` + `pantryLifecycle`'s fresh/past-horizon/excluded counts —
collapse the two into one clause when they tell the same silence, per
`references/lifecycle.md`), printed expiries (`expiredPantryItems` — a fact), and targets
(`hasNutritionTargets`/`daysSinceLastTargets`). **Clean no-op:** nothing stale, no items
past horizon, no expired items, recent logging, targets present — one line, no lecture.
**3. Auto-resolve only the PROVEN set.** `provablyCooked.matches` pairs each stale meal
with the `FOOD-<n>` entry that proves it (same date + slot, food log names the meal's
`MEAL-<n>` id — the proof convention below). For each match:
`update_meal_plan(mealId, status: "cooked")`, citing the proving `FOOD-id`. Never offer a
`log_food` for these — the proof already exists. In approval mode, present the proven
set in the batch too (mirror `/reminders-review` STEP 0) rather than flipping it
silently.
**4. ONE question at most, priority-ordered.** (a) the stale-meal skip-or-name-it batch,
if any remain — *"12 planned meals from 24–41 days ago — mark them all skipped? (name
any you actually cooked)"*; **else** (b) the pantry ramp, when the fresh scope is cold:
**exactly one** action — a photo of the fridge/shelf through `reconcile_pantry` (JOB 2's
bulk path), scoped to **fresh** rows only. On a plain yes to (a):
`update_meal_plan(id, status: "skipped")` for each; name one as actually cooked → flip
it to `cooked` and **offer** a `log_food` for it (**never fabricate one** — a guessed
intake figure is worse than a blank day). Whichever need loses the priority is **stated,
not asked** this run. Never per-item pantry correction or an unconfirmed delete.
**Writes here stay `update_meal_plan` only** — a "yes" to the pantry ramp executes
through JOB 2's `reconcile_pantry`, not from here.
**5. Report the tally**: N auto-closed (with proofs), N proposed, the lifecycle numbers
(fresh / past-horizon / excluded), and — targets missing or stale (~14+ days) — one line
pointing at JOB 5. Idempotent: re-runs converge to nothing new.
**The proof convention.** `FoodLogEntry` has no structured link to a meal-plan entry — a
logged meal fulfilling a planned one names the plan's `MEAL-<n>` id in its `description`
(e.g. `"MEAL-12 — sheet-pan fish with greens"`). JOB 1 and JOB 3 follow this convention —
it's what keeps `provablyCooked` alive. Without it, a meal is only reconciled by a yes.
**Lifecycle scoping and fact vs. inference.** The routine sweep asks only about the
**fresh** scope — spices never raised unattended, staples only on an explicit
stock-take; your judgment always **extends** the scope (a fresh-baked loaf or fresh
fish logged with no location still counts). State a printed `expiredPantryItems` date
as fact; state `likelyPastHorizon` as an inference ("unverified N days, typically past
its useful life") — never blur the two. Full table + wording guide:
[`references/lifecycle.md`](references/lifecycle.md).
> **Example.** 14 stale meals (2 provably cooked), food log quiet 33 days, pantry
> unverified 15 days with 4 of 6 fresh items past their inferred horizon (27 spices + 24
> staples excluded), 1 item past its printed expiry, no targets ever set. Opening line:
> *"14 planned meals are stale (2 provably cooked), the food log's been quiet 33 days,
> the pantry's unverified for 15 — 4 of 6 fresh items look past their usual shelf life
> (27 spices + 24 staples untouched, as expected), 1 item past its printed expiry, and no
> nutrition targets have ever been set."* Auto-close the 2 proven meals citing their
> FOOD-ids; batch the remaining 12 as one skip-or-name-it question. Report: 2
> auto-closed, 12 proposed, the lifecycle tally, no targets.
---
## JOB 1 — Food log ("what I ate")
From a free-text *"what I ate"*, estimate the numbers and `log_food(date, slot,
description, ...)`. A single meal is **low-stakes — log it directly** (then report it).
**1. Pin date + slot.** `date` is `YYYY-MM-DD` (default **today** unless the user says
otherwise — *"yesterday"*, *"this morning"*). `slot` is `breakfast | lunch | dinner |
snack` — infer from wording ("breakfast", "for lunch", "a snack") or from the time of
day; when truly ambiguous, `snack` is the safe catch-all.
**2. Write a clean `description`** (what was eaten, e.g. *"Chicken burrito with rice
and beans"*) and, when the user itemised, an `items` array (*["chicken", "rice",
"beans", "guacamole"]*). `description` is the only required content field. **If this
meal fulfils a planned entry on the meal plan, name its `MEAL-<n>` id in the
description** (e.g. `"MEAL-12 — chicken burrito with rice and beans"`) — that prose
link is the only thing that lets JOB 0's reconcile sweep later prove it was cooked.
**3. Estimate `calories`** with portion heuristics + the reference anchors below. Round
to a sensible figure (nearest 25–50 kcal — false precision helps no one). The numbers
are *guesses*, so **leave `estimated` at its default `true`**; set `estimated: false`
**only** when the user gives a measured/labelled value (*"the packet says 320 kcal"*,
*"my scale read 150 g"*).
**Portion heuristics (eyeball → grams):**
- A palm of cooked protein ≈ 100–120 g; a fist of cooked rice/pasta ≈ 150 g; a cupped
hand of nuts/cereal ≈ 30 g; a thumb of fat (oil/butter/nut butter) ≈ 15 g.
- "A plate" of a mixed main ≈ 600–800 kcal; "a bowl" ≈ 400–600; "a handful" snack ≈
150–250; a restaurant/takeout portion runs 1.3–1.6× a home portion.
- When the user gives a count ("2 eggs", "3 slices"), multiply the per-unit anchor.
**Reference anchors (rough kcal; scale by portion):**
| Food | Typical portion | ~kcal | Note |
|---|---|---|---|
| Egg | 1 large | 75 | +fat if fried |
| Bread / toast | 1 slice | 80 | |
| Cooked rice / pasta | 1 cup (~180 g) | 220 | |
| Chicken breast (cooked) | 100 g | 165 | lean protein |
| Salmon (cooked) | 100 g | 200 | |
| Avocado | ½ | 120 | |
| Cheese | 30 g | 110 | |
| Olive oil / butter | 1 tbsp | 120 | |
| Banana / apple | 1 medium | 95 | |
| Mixed salad (dressed) | 1 bowl | 250 | dressing dominates |
| Burrito (filled) | 1 | 650 | |
| Latte (whole milk) | medium | 150 | black coffee ≈ 5 |
| Beer / wine | 1 serving | 150 | |
**4. Macros — optional, omit when you're guessing in the dark.** Provide
`protein`/`carbs`/`fat` (grams) **only** when the food makes them estimable: a clear
protein source (chicken, eggs, yoghurt, fish), a starch-dominant plate (pasta, rice),
an obviously fatty item. For a vague *"some leftovers"* or *"a bit of everything"*,
**omit macros** — a bad macro split is worse than none. Calories alone is a complete,
honest entry.
**5. Health flag (`health`), optional.** A quick green/amber/red read on the *whole
entry*: `green` = whole-food, balanced, mostly unprocessed (grilled fish + veg);
`amber` = middling / mixed (a sandwich + chips, a latte + pastry); `red` = a treat /
heavily processed / fried / sugary (cake, fast-food meal, a big dessert). When it's
genuinely neutral, omit it — don't force a color.
**6. Write it:** `log_food(date, slot, description, [items], [calories], [protein],
[carbs], [fat], [health], [note])`. Then report the minted `FOOD-id` and the
day's running total (`list_food_log(date: <day>)` gives a per-day kcal rollup).
**Editing / removing.** Correct an entry with `update_food_log(id, …)` (pass only the
changed fields). `delete_food_log(id)` **hard-removes** it (no soft-archive) — so in
approval mode, confirm first.
> **Example.** *"I had a chicken burrito and a coke for lunch"* (today, auto mode):
> estimate burrito ≈ 650, regular coke ≈ 140 → `calories: 790`; protein/carbs/fat
> estimable (≈ `P35 C100 F25`); a burrito-plus-soda lunch → `health: "amber"`;
> `estimated` stays `true`. → `log_food(date: "2026-06-13", slot: "lunch",
> description: "Chicken burrito with a Coke", items: ["chicken burrito", "Coke"],
> calories: 790, protein: 35, carbs: 100, fat: 25, health: "amber")`. Report
> `FOOD-n` + today's total.
---
## JOB 2 — Pantry (the inventory)
Keep "what's on hand" current with `add_pantry_item` / `read_pantry` /
`update_pantry_item` / `remove_pantry_item`. A single add/update is low-stakes — do it
directly.
**Always `read_pantry` before you add.** The store does **NOT** enforce name
uniqueness, so **you** dedup: match on the **lowercased `name`** (treat *"Greek
Yoghurt"*, *"greek yogurt"* as the same item). If it's already there, **`update_pantry_item`**
the existing row (bump `quantity`, clear `lowStock`, refresh `expiresAt`) rather than
adding a duplicate.
**Set the fields sensibly on add:**
- **`category`** — `produce | protein | dairy | grain | pantry | frozen | spice | other`.
Pick the obvious one (spinach → produce, chicken → protein, rice → grain, tinned
beans → pantry, peas-in-the-freezer → frozen); `other` only when nothing fits.
- **`location`** — `fridge | freezer | pantry`. Perishables → fridge, anything frozen →
freezer, dry/tinned goods → pantry.
- **`quantity` + `unit`** when the user gives them (*"2 cans"* → `quantity: 2, unit:
"cans"`; *"500 g"* → `quantity: 500, unit: "g"`); leave both off for a vague *"some
pasta"*.
- **`expiresAt`** (`YYYY-MM-DD`) when stated or printed on the pack, or the user gives a
shelf life (*"good for a week"*) — that's **their** data, compute it. **Never write
your own guess** — an absent `expiresAt` is still monitored via JOB 0's computed
freshness horizon (never stored).
- **`lowStock`** — set `true` when the user says they're **running low / nearly out**
(*"we're low on milk"*). Clear it (`lowStock: false`) when they restock.
**Surface what's expiring / low.** `read_pantry` renders items grouped by category and
flags **expiring-soon** (within 3 days, or already `EXPIRED`) and `LOW` items. When the
user asks *"what's in my fridge"* or *"what's going off"*, run `read_pantry` (filter by
`location` / `category` / `expiringBefore` / `lowStock` as asked) and **lead with the
expiring-soon and low-stock items** — that's the actionable part.
**Removing / using up — the rule: a `quantity: 0` item is a BUG, never a state.** When the
user **finishes / uses up / throws out** an item (*"we're out of milk"*, *"finished the
eggs"*), **`remove_pantry_item(id)`** it — do **NOT** `update_pantry_item(id, quantity: 0)`.
A zero-quantity row is a ghost that clutters the pantry; "gone" is *removed*, not *zero*.
- **Fully consumed → `remove_pantry_item(id)`.** It hard-removes (no soft-archive). In
approval mode confirm first. A removed item leaves any meal-plan `pantryItemIds`
referencing it **dangling — that's tolerated**, don't chase the refs.
- **Partially consumed (some left) → `update_pantry_item(id, quantity: <remaining>)`.**
Decrement only while there's a positive amount left. The moment it would hit 0, **remove
it instead.** (Don't have an exact count? If they say it's *finished*, remove; if they say
*running low*, keep it and set `lowStock: true`.)
> **Example.** *"add 2 cans of chickpeas and we're low on olive oil"* (auto mode):
> `read_pantry` first. Chickpeas absent → `add_pantry_item(name: "Chickpeas", quantity:
> 2, unit: "cans", category: "pantry", location: "pantry")`. Olive oil already present →
> `update_pantry_item(<id>, lowStock: true)` rather than adding a second row.
### Bulk capture — a photo of a receipt or a fridge shelf
For a **whole shop or a whole shelf** — not one item — a photo beats twenty-five individual
`add_pantry_item` calls. This path is **one extraction, one merge pass, one confirmation, one
write**; a single ad-hoc add still goes through `add_pantry_item` / `read_pantry` above. The
**mechanical** half of dedup (spelling, casing, plurals, accents) is now enforced by
`reconcile_pantry` itself — it upserts by a normalised name, so you no longer hand-match those.
**Semantic aliases stay yours to resolve** (step 2). Worked examples, the ambiguous-case gallery,
and receipt-extraction tips live in
[`references/pantry-capture.md`](references/pantry-capture.md) — read it the first time you run
this job.
1. **Extract** the items from the photo in your own context (vision is your job; the board never
sees the image): `name`, `quantity`+`unit` when legible, `category`, `location`, `expiresAt`
when printed. Skip non-food lines (bags, deposits, discounts).
2. **`read_pantry`**, then resolve the **semantic aliases** the route cannot — the same food in
two languages, or at two pack sizes, is ONE item; merge before submitting (worked examples in
the reference doc). Never submit an alias you haven't resolved — `reconcile_pantry` will
happily add it as new. (`read_pantry` carries no lifecycle/horizon fields — a stock-take leads
with the fresh + `lowStock` rows JOB 0's status read already surfaced.)
3. **Propose ONE collapsed diff and get ONE yes — even in auto mode** (a photo extraction is
fallible, so this bulk write always confirms, per STEP 0's bulk rule): counts first, only the
genuinely ambiguous items named — *"+9 new, 4 updated, 2 look like duplicates of
PANTRY-12/PANTRY-31 — merge? 1 item expired 26 days ago — remove it?"* Never a per-item prompt.
4. **On yes:** one `reconcile_pantry(items)` call, then `remove_pantry_item` for each expired
item Philip approved removing (expiry proposals come from `read_pantry`'s `EXPIRED` flags /
`get_nutrition_status`'s `expiredPantryItems`) — removal stays the explicit tool;
`reconcile_pantry` never deletes.
5. **Report** the diff the tool returned: added / updated / skipped, and the new version.
---
## JOB 3 — Meal plan / Chef ("what can I cook", "plan the week")
Plan meals **from what's on hand**. The whole point is to cook the pantry down,
especially the expiring items.
**1. `get_diet_profile` + `read_pantry` FIRST — always.** Call **`get_diet_profile`** and
read `allergies` (NEVER plan a meal containing one), `dietType` (vegan/halal/keto — honor
it), and `notes` (soft preferences) — see the safety callout up top; if it errors, STOP and
ask the user to confirm allergies before planning. Then `read_pantry`: you cannot plan well
without the inventory. Note especially the **expiring-soon** and **low-stock** items; a good
plan **uses up what's about to go off** before it spoils — within the dietary constraints.
**2. Build each meal.** Prefer recipes that lean on **on-hand + expiring** ingredients;
fill gaps with a short shopping note rather than ignoring the pantry. For each meal you
plan, assemble:
- a `title` (*"Sheet-pan salmon & broccoli"*),
- an `ingredients` list,
- optionally a `recipe` (a few steps or a link) and `servings`,
- **`pantryItemIds`** — the `PANTRY-ids` of the on-hand items this meal consumes (SOFT
refs; not validated; dangling is tolerated — so it's safe to reference them).
**3. `plan_meal(date, slot, title, [recipe], [ingredients], [servings],
[pantryItemIds], [eventId])`** — one call per `(date, slot)`. New entries default to
`status: "planned"`.
> **Approval-mode gate (STEP 0).** Planning **a whole week** is a BULK write — many
> `plan_meal` calls, plus the default calendar push (item 4 below). In approval mode,
> lay the proposed plan out **in chat** (day ▸ slot ▸ title) and get **one** yes
> covering both the plan AND putting it on the calendar, before firing either. In
> auto mode, plan it, push it, and report. **One** planned meal is low-stakes either way.
**4. Push the week to the calendar — by default.** Before pushing, **read the user's
REAL calendar** (your own Google Calendar connector) for the window and collect its
busy times — **only** `{date, start, end}`, never a title, attendee, or any other
content; skip this if you can't reach a real calendar, it's optional. Then call the
`nutrition` MCP's **`push_meal_plan_to_calendar({ from, to, busy_windows: [...] })`**
for that window (omit `from`/`to` for today through the next 7 days; omit
`busy_windows` if you have none). **Never ask Cos to store this calendar data** — the
tool uses it for this one call only and discards it. It is **idempotent and
overlap-safe**: a meal lands in a free slot within its slot's candidate window and is
never placed on top of an existing timed event or inside the user's **working hours**
(Mon–Fri 09:00–18:00 by default, or whatever the board has stored — automatic, you
don't set it here; this is why a weekday lunch/breakfast/snack can come back
`skipped`/`outside_working_hours` while the same day's dinner still places — tell the
user that's a policy skip, not a fully-booked day). `cooked`/`skipped` entries in the
window are reported skipped/`not_planned` and left alone. Re-running it reconciles
rather than duplicates, so it's safe every time this job runs. This is the **same**
approval-mode confirmation as the plan itself (see the gate above) — one combined yes
for "plan the week AND put it on the calendar", never a second prompt.
**Explicit-time requests still go the manual route.** When the user names a specific
time (*"put dinner on my calendar at 7"*), the `eventId` must reference an
**existing** CalendarEvent or `plan_meal` rejects the write — **create the event
first** via the **`calendar`** MCP (`create_event(title, date, [startTime], …)`
returns the minted `EVT-id`), then pass that id as `eventId` to `plan_meal` (or
`update_meal_plan(id, eventId: "EVT-n")` to link an existing planned meal). Pass
`eventId: null` to `update_meal_plan` to **unlink**. A meal placed this way already
carries a receipt, so the default push above treats it as a live link and only
refreshes its content — it won't move the time you set.
**5. Cooking & status.** Mark progress with `update_meal_plan(id, status: …)`:
`cooked` (made it), `skipped` (didn't). When the user says they **cooked** a planned
meal:
- set `status: "cooked"`, **and**
- **offer to `log_food`** a matching food-log entry for it (same date; slot from the
plan; description/items from the title + ingredients, **naming the plan's `MEAL-<n>`
id in the description** per the JOB 0 proof convention; estimate calories/macros per
JOB 1) — a cooked meal is usually a meal eaten, so close the loop, but **offer**, the
user may have logged it already or be cooking for others;
- **offer to update the pantry** — the cooked meal consumed its `pantryItemIds`, so per
JOB 2: **`remove_pantry_item` the items it used UP**, only `update_pantry_item(quantity:
<remaining>)` ones with some left, and `lowStock: true` ones now running low. **Never
leave a `quantity: 0` row** — used up means *removed*. Surface this; don't silently mutate inventory.
**Reading the plan.** `list_meal_plan(from, to, [slot], [status])` renders a per-day
agenda (use a `from`/`to` window for "this week"); `get_meal_plan(id)` shows one entry
in full (recipe, ingredients, linked pantry items, linked event). `remove_meal_plan(id)`
hard-removes a planned meal (confirm in approval mode); it does **not** touch a linked
CalendarEvent — delete that separately via the calendar MCP if the user wants it gone.
> **Example.** *"what can I cook tonight?"* (auto mode): `read_pantry` → salmon (exp in
> 2 days), broccoli, lemon, rice on hand. Plan around the expiring salmon →
> `plan_meal(date: "2026-06-13", slot: "dinner", title: "Sheet-pan salmon with broccoli
> & rice", ingredients: ["salmon", "broccoli", "lemon", "rice"], servings: 2,
> pantryItemIds: ["PANTRY-4", "PANTRY-7", "PANTRY-9", "PANTRY-11"])`. Report the
> `MEAL-id` and that it uses the salmon before it expires. Later, *"I cooked it"* →
> `update_meal_plan(MEAL-n, status: "cooked")`, then offer to `log_food` dinner and to
> decrement the salmon/broccoli in the pantry.
---
## JOB 4 — Dietary profile ("set my allergies", "I'm vegan")
The dietary profile is ONE nutrition-owned record — `get_diet_profile` / `set_diet_profile`:
- **`allergies: string[]`** — the SAFETY list (you never plan/serve these — see the top callout).
- **`dietType: string[]`** — regime tags (free strings): `["vegan"]`, `["halal","no-pork"]`, `["keto"]`.
- **`notes`** — free text: intolerances, foods avoided, non-allergy issues (*"gluten bloats me"*), preferences.
- **`philosophy`** — the free-text **"views on diet"** methodology you follow when authoring targets
(a study-grounded default ships; the user can overwrite it for keto/vegan/their coach's plan).
**`set_diet_profile` MERGES (present keys only) — and a sent list REPLACES that list.** So to ADD
an allergy, send the FULL new array: *"I'm allergic to peanuts"* → first `get_diet_profile`, then
`set_diet_profile(allergies: [...existing, "peanuts"])`. *"I'm vegan now"* →
`set_diet_profile(dietType: ["vegan"])`. *"gluten makes me bloat"* → append to `notes`. A single
dietary write is **low-stakes — do it directly**, then read it back. (Setting allergies is the one
place to be extra careful: confirm the spelling/scope with the user.)
---
## JOB 5 — Author the daily nutrition targets ("what's my calorie target", "how am I doing")
**The board no longer computes this — YOU author it** (the `save_training_plan` law). The flow is
**FETCH → AUTHOR → PERSIST**:
**1. FETCH the inputs** (all reads, no confirmation needed):
- **`get_body_objective`** (body MCP) — the user's FREE-TEXT goal + the target-weight anchor + activity.
If it returns nothing, there's no goal yet → tell the user to set it (the **/body** page or the body
skill) and offer to help; don't invent one.
- **`get_body_status`** (body MCP) — the physiology FACTS: derived age, current/trend weight, BMR,
estimated + measured TDEE (and which basis), BMI, fat-free mass, latest waist. These are the numbers
you build on — **not** a recommendation.
- **`get_diet_profile`** — the dietary constraints AND the **`philosophy`** (the methodology to apply).
- **`list_food_log`** (+ `list_weights` via the body MCP if useful) — recent intake / the trend, for
the closed-loop correction.
**2. AUTHOR the targets** in your own reasoning, applying the **`philosophy`** to the goal + the facts:
maintenance (TDEE) is the hub; the goal's direction (the free text — fat loss / muscle / recomp /
maintenance) sets a calorie **offset**; protein-first macros per the philosophy; respect the sex
calorie floor (1500 male / 1200 female). Read the goal as PROSE — a vegan lean-bulk, a "lose a bit but
keep my strength" recomp, etc. — and translate it into numbers. (The shipped default philosophy carries
the full method — offsets, protein coefficients by training status, the energy-availability floor,
recomp-off-body-comp — read it.)
**3. PERSIST with `save_nutrition_targets`** — `periodKey` defaults to today; put the plan in `payload`:
`{ daily_calories (required number), protein_g, fat_g, carbs_g, stance ("deficit"|"surplus"|"maintenance"),
rationale (a sentence: why these numbers, citing the goal + philosophy) }`. The board validates the shape,
attributes it to you (`source:"agent"`), versions it (it lands on the **/body** + food-log panels live),
and returns `warnings` (e.g. a below-floor calorie note) — **surface them**. Upserts by day, so
re-authoring today's targets replaces them.
**Reading back.** `get_nutrition_targets` returns the latest saved daily target (calories + macros +
your rationale); `list_nutrition_targets(from?, to?)` is the history. For *"how am I doing?"* read the
latest target + `list_food_log` for the day/week and compare conversationally (you do the adherence
read now — there's no per-day chip).
> **Example.** *"what's my calorie target?"* (auto mode): `get_body_objective` → *"Lose some fat but
> keep my strength; target 80 kg; activity moderate."* `get_body_status` → 90 kg, age 38, BMR 1850,
> TDEE est 2868. `get_diet_profile` → no allergies, default philosophy. AUTHOR: a sustainable cut at
> ~−500 kcal → **2350 kcal**, protein-first to defend muscle (≈ 2.0 g/kg → 160 g), fat 70 g, carbs ~265
> g. `save_nutrition_targets(payload: { daily_calories: 2350, protein_g: 160, fat_g: 70, carbs_g: 265,
> stance: "deficit", rationale: "~500 kcal below your ~2868 maintenance for a sustainable cut; protein
> high to keep strength while losing." })`. Report the numbers + *"informational, not medical advice."*
---
## JOB 6 — The shopping list ("what should I buy", the Friday draft)
The shopping list (`db.shoppingItems`) is **persistent state** — unlike a candidate
suggestion, a row you write survives between shops, and it deliberately holds **non-food
too** (the brief is explicit: "not only about nutrition"). This job reads the list, drafts
against it on Fridays, and ticks items off as they get bought.
**1. Triggers.** "what's on my shopping list", "add X to my list", "I bought / got the
milk", and the Friday scheduled run ("take stock of the pantry and draft the shopping
list").
**2. Read first, every time.** `list_shopping()` (defaults to `needed`) +
`get_shopping_candidates()` (defaults to the coming week). Consume every field the read
returns by name: state the **window** it covers, walk the **candidates**, and report the
**suppressed** counts in one line — N already listed, N in pantry, N bought this window.
**3. The Friday draft.** By the time this runs, JOB 0 has already reconciled. Split the
candidates into two sets:
- the **proven set** — every `source: "plan"` candidate: an ingredient this week's plan
names that the pantry does not hold.
- the **judgement set** — every `source: "pantry"` candidate (an `expired` row is a FACT;
a freshness-horizon row is an INFERENCE — surface it with its `(inferred — no printed
date)` label intact, never paraphrased) plus anything you know from context the state
can't see (a non-food need, something mentioned in chat).
**4. Write it — batched, never per item.** **auto mode**: `add_shopping_item` each proven
row directly (`source: "plan"`, `sourceRef` the meal's id), log every write, then ask **at
most one** consolidated question covering the whole judgement set. **approval mode**: lay
the whole proposed list out in chat (proven + judgement together) and take **exactly one**
yes covering all of it. Never a prompt per item. **Nothing routes through the pending
queue** — confirmation here is conversational, exactly like JOB 2's bulk capture.
**5. A clean list is a no-op.** No candidates and nothing left to ask → produce **no
output at all** — don't announce "nothing to buy," say nothing (the `/reminders-review`
don't-chase-silence contract).
**6. Ticking off.** `update_shopping_item(id, status: "bought")` — one call per tick;
`boughtAt` stamps itself, you never set it yourself. After the ticks, **offer** one
`reconcile_pantry` covering the bought rows that are actually food (JOB 2 owns bulk pantry
writes) — offer, never silently mutate the inventory. `household` / `personal-care` /
`bakery` rows are **excluded** from that offer (the pantry vocabulary has no slot for
them) — say which rows you left out.
**7. Category mapping (the two vocabularies differ).** A pantry-derived candidate whose
pantry row is `grain` or `spice` gets shopping category `pantry`; every other pantry
category name maps straight across (`produce`→`produce`, `protein`→`protein`,
`dairy`→`dairy`, `frozen`→`frozen`). When you're unsure, **omit `category`** rather than
guess.
**8. Provenance for accepted inferences.** When a confirmed **inferred** (freshness-
horizon) candidate is written onto the list, carry its `reason` — the label included —
into the new row's `note`, so an inference that makes it into stored state keeps saying it
was one.
**9. "Don't need it" / removing.** `update_shopping_item(id, status: "dismissed")` keeps
history and is inert — a dismissed row never suppresses a future candidate (a standing
"never offer X again" memory is a bigger semantic than one label). `remove_shopping_item`
only on an explicit delete ask — hard remove, confirm in approval mode.
**10. Report** the minted `SHOP-` ids and the aisle-grouped list (`list_shopping` already
renders it grouped by category).
> **Example.** Friday, auto mode, right after JOB 0's reconcile. `list_shopping()` → 3
> `needed` rows already on it (milk, olive oil, batteries). `get_shopping_candidates()` →
> window `2026-08-07` → `2026-08-13`; candidates: `flour` (`source: "plan"`, for "Sunday
> pancakes" on 2026-08-09), `spinach` (`source: "pantry"`, `expired 2026-08-04`),
> `Salad leaves` (`source: "pantry"`, likely past its ~7-day freshness horizon at 12 days
> (inferred — no printed date)); suppressed: 1 already listed, 2 in pantry, 0 bought this
> window. Proven set = {flour} → write it directly: `add_shopping_item(name: "flour",
> source: "plan", sourceRef: "MEAL-41")`. Judgement set = {spinach, Salad leaves} → **one**
> consolidated question, the label kept verbatim: *"Also add spinach (expired 2026-08-04)
> and Salad leaves (likely past its ~7-day freshness horizon at 12 days (inferred — no
> printed date))?"* On yes: `add_shopping_item(name: "spinach", source: "pantry",
> sourceRef: "PANTRY-9")` and `add_shopping_item(name: "Salad leaves", source: "pantry",
> sourceRef: "PANTRY-22", note: "likely past its ~7-day freshness horizon at 12 days
> (inferred — no printed date)")`.
> Report `SHOP-14`, `SHOP-15`, `SHOP-16` + the aisle-grouped list.
---
## Conventions (guardrails recap)
- **`nutrition` MCP only, via the tools.** Never `bash`/`curl`. The board UI is the
read twin; you do the writing.
- **The add-on must be ENABLED for writes.** A disabled add-on 404s every write ("Not
found.") while reads stay open — tell the user to flip it on at **/addons**; you
don't enable it yourself.
- **Mode (STEP 0):** auto → just do it; approval → confirm **bulk** writes (a week of
`plan_meal`, batch logs) **in chat** before firing, and confirm **destructive**
removes. A single write is low-stakes either way. **There is no pending/propose
queue** — confirmation is conversational.
- **Reconcile first (JOB 0), every invocation — all fields, not just the meal plan.**
`get_nutrition_status` → state the opening picture → auto-flip only `provablyCooked` to
`cooked` (citing the proof) → ONE priority-ordered question at most (the meal batch,
else the pantry ramp, **lifecycle-scoped** to fresh rows — see `references/lifecycle.md`).
Never invent a `log_food` entry or back-fill a missed day. A clean surface no-ops in
one line.
- **Food log:** estimate calories with the portion heuristics + anchor table; keep
`estimated: true` (set false only for a measured value); macros are optional —
**omit when you can't honestly estimate them**; health flag is an optional whole-meal
green/amber/red.
- **Pantry:** `read_pantry` before adding; **dedup by lowercased `name`** (the store
doesn't enforce uniqueness) — update the existing row, don't duplicate; set
category/location/expiry/lowStock sensibly; lead with expiring-soon + low-stock when
asked what's on hand.
- **Bulk capture (photo → pantry):** extract → `read_pantry` → merge semantic aliases
yourself → propose ONE collapsed diff → ONE confirmation (always, even in auto mode)
→ one `reconcile_pantry` call. Expired items are proposed for removal in the same
confirmation; removal is never automatic — `reconcile_pantry` itself never deletes.
- **Meal plan:** `read_pantry` **first**; prefer on-hand + expiring ingredients; record
`pantryItemIds` (soft refs). Calendar push is the **default** —
`push_meal_plan_to_calendar` after planning/reconciling, idempotent + overlap-safe,
folded into the same approval-mode confirmation as the plan. Read the user's real
calendar first and pass its busy times as `busy_windows` (date/start/end only —
never store the content); working hours are protected automatically either way. An
explicit named time still goes the manual route: `create_event` (calendar MCP)
first, then store the `EVT-id` as `eventId`; `null` unlinks. `status: "cooked"` →
**offer** a `log_food` entry **and** a pantry decrement.
- **Dietary profile (JOB 4):** `get_diet_profile` / `set_diet_profile` (MERGE — a sent
list REPLACES it, so add by sending the full array). `allergies` is the SAFETY list you
honor everywhere; `philosophy` is the methodology you apply when authoring targets.
- **Targets (JOB 5) — YOU author them, the board does not.** FETCH (`get_body_objective` +
`get_body_status` + `get_diet_profile` + `list_food_log`) → AUTHOR the calories/macros
yourself → PERSIST with `save_nutrition_targets`. Surface the returned `warnings` + the
not-medical-advice framing. Read back with `get_nutrition_targets` / `list_nutrition_targets`.
**Weight + the body goal are the `body` MCP's** (`log_weight` / `get_body_objective` /
`get_body_status`), not this skill's — this skill READS them.
- **Shopping list (JOB 6):** persistent state (`db.shoppingItems`, non-food too) —
`get_shopping_candidates` **computes** suggestions, it never auto-writes them. Auto
mode writes only the proven `source: "plan"` set directly, then **at most one** batched
question for the judgement (pantry) set; approval mode takes **exactly one** yes for
the whole proposed list. A clean list is a no-op — **no output at all**. Ticking
`bought` **offers one `reconcile_pantry`** for the food rows (never silent). `dismissed`
keeps history (inert — never suppresses a future candidate). Removes are hard, like
every other delete in this skill.
- **NOT MEDICAL ADVICE.** Targets are informational estimates — **say so**, surface the
engine's `not-medical-advice` flag, and **defer medical conditions, pregnancy/
breastfeeding, eating-disorder history, or an under-18 user to a clinician or
registered dietitian** (recommend they consult one; don't push a deficit).
- **Removes are HARD.** `delete_food_log` / `remove_pantry_item` / `remove_meal_plan`
have no soft-archive — they're irreversible, unlike the board's soft `archive_case`.
Confirm before removing in approval mode.
- **Report** what you wrote: the minted ids (`FOOD-`/`PANTRY-`/`MEAL-`/`NTARGET-`/`SHOP-`)
and the useful rollup (the day's calorie total, what's expiring, the week's agenda, the
new weight trend + remaining-to-go, the aisle-grouped shopping list).
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!