Imported from philipyaz/cos (
board/.claude/skills/nutrition-chef/SKILL.md). Install upstream withnpx skills add philipyaz/cos --skill nutrition-chef. Copyright stays with the author.
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_planlaw). 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 withsave_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
bodyMCP, 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 withget_body_objective(the goal) andget_body_status(the facts); log a weigh-in with the body MCP'slog_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 ashuman). 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_profileand read itsallergies,dietType, andnotes. Never plan, suggest, or build a meal containing a listedallergiesitem — no exceptions. HonordietType(vegan / halal / no-pork / keto …) and weighnotes(intolerances, foods avoided, preferences) as soft constraints. Ifget_diet_profileerrors 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
warningsthe board returns onsave_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 ofplan_mealcalls, 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, oneadd_pantry_item, one planned meal, oneset_diet_profile, onesave_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 thePANTRY —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): titlePANTRY — <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.
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";estimatedstaystrue. →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"). ReportFOOD-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);otheronly when nothing fits.location—fridge | freezer | pantry. Perishables → fridge, anything frozen → freezer, dry/tinned goods → pantry.quantity+unitwhen 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 absentexpiresAtis still monitored via JOB 0's computed freshness horizon (never stored).lowStock— settruewhen 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-planpantryItemIdsreferencing 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 setlowStock: true.)
Example. "add 2 cans of chickpeas and we're low on olive oil" (auto mode):
read_pantryfirst. 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 — read it the first time you run
this job.
- Extract the items from the photo in your own context (vision is your job; the board never
sees the image):
name,quantity+unitwhen legible,category,location,expiresAtwhen printed. Skip non-food lines (bags, deposits, discounts). 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_pantrywill happily add it as new. (read_pantrycarries no lifecycle/horizon fields — a stock-take leads with the fresh +lowStockrows JOB 0's status read already surfaced.)- 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.
- On yes: one
reconcile_pantry(items)call, thenremove_pantry_itemfor each expired item Philip approved removing (expiry proposals come fromread_pantry'sEXPIREDflags /get_nutrition_status'sexpiredPantryItems) — removal stays the explicit tool;reconcile_pantrynever deletes. - 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
ingredientslist, - optionally a
recipe(a few steps or a link) andservings, pantryItemIds— thePANTRY-idsof 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_mealcalls, 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_fooda matching food-log entry for it (same date; slot from the plan; description/items from the title + ingredients, naming the plan'sMEAL-<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_itemthe items it used UP, onlyupdate_pantry_item(quantity: <remaining>)ones with some left, andlowStock: trueones now running low. Never leave aquantity: 0row — 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 theMEAL-idand that it uses the salmon before it expires. Later, "I cooked it" →update_meal_plan(MEAL-n, status: "cooked"), then offer tolog_fooddinner 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 thephilosophy(the methodology to apply).list_food_log(+list_weightsvia 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 (anexpiredrow 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()→ 3neededrows already on it (milk, olive oil, batteries).get_shopping_candidates()→ window2026-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")andadd_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)"). ReportSHOP-14,SHOP-15,SHOP-16+ the aisle-grouped list.
Conventions (guardrails recap)
nutritionMCP for every nutrition write, via the tools. Neverbash/curl. Two sanctioned cross-MCP writes only: thecalendarMCP for an explicit-time event (JOB 3), and theboardMCP'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 onlyprovablyCookedtocooked(citing the proof) → ONE priority-ordered question at most (the meal batch, else the pantry ramp, lifecycle-scoped to fresh rows — seereferences/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 alog_foodentry 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_pantrybefore adding; dedup by lowercasedname(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) → onereconcile_pantrycall. Expired items are proposed for removal in the same confirmation; removal is never automatic —reconcile_pantryitself never deletes. - Meal plan:
read_pantryfirst; prefer on-hand + expiring ingredients; recordpantryItemIds(soft refs). Calendar push is the default —push_meal_plan_to_calendarafter planning/reconciling, idempotent + overlap-safe, folded into the same approval-mode confirmation as the plan, reporting theunpushedPlannedMealsfigure afterward. Read the user's real calendar first and pass its busy times asbusy_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 theEVT-idaseventId;nullunlinks.status: "cooked"→ offer alog_foodentry 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).allergiesis the SAFETY list you honor everywhere;philosophyis 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 withsave_nutrition_targets. Surface the returnedwarnings+ the not-medical-advice framing. Read back withget_nutrition_targets/list_nutrition_targets. Weight + the body goal are thebodyMCP'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_candidatescomputes suggestions, it never auto-writes them. Auto mode writes only the provensource: "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. Tickingboughtoffers onereconcile_pantryfor the food rows (never silent).dismissedkeeps 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-adviceflag, 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_planhave no soft-archive — they're irreversible, unlike the board's softarchive_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).