Skip to content
Back to skills

Nutrition Chef

ASecurity

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

  • 4 stars
  • 0 votes
  • 0 copies
  • 4 views
  • Added September 2, 2026
ai-agentsgobashrailsgit

Works with

  • cli
  • mcp

Security analysis

A100/100

Pro scans all 3 files and shows the line behind each finding

Scanned September 20, 2026

npx -y skills add philipyaz/cos --skill nutrition-chef --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Nutrition Chef?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Nutrition Chef
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/philipyaz-nutrition-chef/badge)](https://www.skillsdirectory.com/skills/philipyaz-nutrition-chef)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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), the calendar
hand-off (`unpushedPlannedMeals` — upcoming planned meals not yet on the calendar), and
targets (`hasNutritionTargets`/`daysSinceLastTargets`). **Clean no-op:** nothing stale, no
items past horizon, no expired items, recent logging, nothing unpushed, targets present —
one line, no lecture.

**3. Consume ticks on the standing close-out reminder — before anything below, every mode.**
`list_reminders { status:"open" }`, exact title match on **`Meal plan close-out — planned
meals awaiting an answer`**; found → `get_reminder` it and read each ticked task as Philip's
own confirm-skipped answer: `update_meal_plan(id, status: "skipped")`, citing the tick. A
ticked task is a meal answer only when its title names a MEAL id; a tick on the pantry ramp
task is carried, never read as a recapture — only a pantry write clears it (by driving the
past-horizon count to 0). A ticked meal is resolved — it drops out of items 4–6 below.

**4. 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.

**5. ONE question at most, priority-ordered — questions only.** (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 — item 6's deposit is what
makes that statement durable. 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. **Unattended (a scheduled run — nobody in the chat to
answer): ask nothing; item 6 deposits (a)'s remainder instead.**

**6. The standing close-out reminder — the deposit, every mode.** The pantry ramp deposit is a
write, never a question — decided in every mode by the past-horizon count alone, spending none
of the one-question budget (attended or unattended, auto or approval, and regardless of whether
item 5 just asked (b) live). It is the same pure-write, state-and-move-on category as item 8's
calendar push. Keep exactly one open close-out reminder — find it by its exact
title and update it in place; never mint a second. Its task list carries two kinds:

- **Meal tasks — the unattended path only** (an attended run asked (a) live instead): one task
  per remaining stale meal (`MEAL-<n> — <date> <slot>: <title> — tick to confirm SKIPPED
  (cooked? tell Cos or log it)`, `done:false`). An attended write carries the reminder's
  existing MEAL tasks unchanged instead.
- **The ramp task — every mode**, exactly one, present iff `likelyPastHorizon.count > 0`; its
  stable key is the `PANTRY — ` title prefix. It states the past-horizon count and the oldest
  unverified age, and names exactly one action — the JOB 2 photo path (`reconcile_pantry`):
  title `PANTRY — <count> fresh items likely past horizon, oldest unverified <ageDays> days —
  send Cos a fridge/shelf photo (JOB 2)`, `done:false`. Count 0 → the ramp task is dropped.

None yet and any task is due → `create_reminder`; one exists → `update_reminder` with the FULL
task list, id-less, `done` explicit on every item — an omitted `done` resets a tick, and a
deposit for one kind never drops the other kind's tasks. The reminder still exists → complete it
only when both are empty — no meal task remains and no ramp is due (`complete_reminder`). A
clean run deposits nothing.

**7. Report the tally**: N auto-closed (with proofs), N proposed, the lifecycle numbers
(fresh / past-horizon / excluded), the close-out reminder id when one was
deposited/updated/completed, and — targets missing or stale (~14+ days) — one line
pointing at JOB 5. Idempotent: re-runs converge to nothing new.

**8. Nonzero `unpushedPlannedMeals` → push, don't ask.** It's a pure write with no question
attached. **Auto mode:** call `push_meal_plan_to_calendar` with an explicit `from`/`to`
spanning the unpushed dates (the default window is only `[today, today+7)` — a meal planned
further out is otherwise counted by the signal and missed by the push; the same idempotent
call JOB 3.4 makes) and report the before/after counts. **Approval mode:** state the number
here and fold the push into JOB 3.4's existing single confirmation — this is a
state-and-move-on, not a second question; JOB 0's one-question budget (item 5) stays intact.

**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, the close-out reminder id (the ramp
> task rode it — 4 past horizon), 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.

After the push, re-read `get_nutrition_status` and report the `unpushedPlannedMeals`
figure — "now 0" when it cleared, or which meals still lack a receipt and why (the push's
own per-meal `skipped` reason, or a date that fell outside the window just pushed).

**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 for every nutrition write, via the tools.** Never `bash`/`curl`. Two
  sanctioned cross-MCP writes only: the `calendar` MCP for an explicit-time event (JOB 3),
  and the `board` MCP's reminder tools (`create_reminder` / `update_reminder` /
  `complete_reminder`) for JOB 0's close-out deposit. 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`)
  → the close-out deposit, every mode (the ramp rides it whenever the past-horizon count is
  nonzero — a write, never a question). 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, reporting the
  `unpushedPlannedMeals` figure afterward. 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).

Files in this skill

  • SKILL.md41.4 KB
  • references/lifecycle.md4.3 KB
  • references/pantry-capture.md5.3 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.