---
title: The feeder that tells you how they actually ate — pet stats and alerts
series: Build It Yourself — briefs
brief: 06
license: CC BY 4.0
tested_on: Claude Code 2.1 (Oct 2026) · Node 25 · macOS 26 — steps 1–4 built and run by a fresh headless session (Home Assistant replaced by a local HTTP stub and a fixtures file, LLM replaced by canned replies, notify.sh faked, clock faked with --at); step 5 (scheduler and wall page) written but not run
effort: one evening (3 h) for the numbers and alerts; +1 h for the weekly note and the wall page
---

# The feeder that tells you how they actually ate

## TL;DR
A smart pet feeder knows how much it *dropped*, and a scale under the bowl knows how much is
*left*. Neither number is "how much they ate" — and the app that ships with the feeder will
happily show you the wrong one. This brief turns your feeder into a small honest data
source: a daily log that outlives the device's own history, a handful of plain-arithmetic
conclusions ("down 22% on last week, two days under the recommended range"), alerts that
fire only when something is actually wrong, and one short weekly note written by an LLM that
is allowed to talk *only* about those numbers. Most of the brief is the traps, because
nearly every sensor on a feeder means something slightly different from its name.

---

## What you get (a scene)

> *(Sunday 09:15 — a message from your assistant)*
> **Assistant:** "Both cats ate 14% less than last week (avg 118 g vs 137 g). Two evenings
> this week the bowl still held ~40 g at bedtime. Weight is steady. Not worth a vet visit
> yet — if it's still down next Sunday, I'd bring it up. Water-filter life: 6 days left."
>
> *(Wednesday 21:05 — the only alert of the month)*
> **Assistant:** "Today the cats ate 31 g — under half their usual. Worth a look tonight."

You do not get a dashboard of twenty charts you never open. You get one sentence a week and
silence in between, unless the bowl says otherwise.

---

## Prerequisites

- The always-on machine and the `notify.sh` chat line from **brief 00**
  (`start-here-always-on-agent.md`).
- **A smart feeder with a scale under the bowl** that is reachable from your machine. We use
  a Wi-Fi feeder bridged into **Home Assistant** (free, runs on the same machine); the
  brief reads Home Assistant's REST API, so any feeder Home Assistant can see works. If your
  feeder has its own local API, the adapter in step 1 is the only file that changes.
- A Home Assistant **long-lived access token** (Profile → Security), kept in your OS secret
  store and read at start-up — never in code.
- Optional: a weight for each pet (a kitchen scale is enough), and an LLM API key for the
  weekly note (a few cents a month; step 4 runs without one).

---

## Architecture (the 15-line version)

```
 feeder ──▶ Home Assistant ──▶ adapter.js  (the ONLY file that knows entity names)
                                   │  readings: dispensed events · eaten events · bowl grams
                                   ▼
                     collector.js  every 15 min ─▶ log.json   {date: {dispensed_g, eaten_g,
                                   │                           sessions, bowl_samples[]}}
                                   ▼
                     facts.js (pure arithmetic, no network, no LLM)
                        trend vs prior week · days below/above range · leftover · peak hours
                                   │
                  ┌────────────────┼──────────────────┐
                  ▼                ▼                  ▼
            watch.js          weekly.js           page.html
        (alerts, once/day)  (LLM writes 4 lines,   (one screen on a
                             from facts ONLY)       wall tablet / phone)
```

1. **Your own log is the source of truth, not the device's history.** The feeder platform
   keeps a few days; you want months. The collector copies each day's totals into a file
   you own.
2. **The adapter is the only code that knows entity names.** Everything downstream sees
   `{dispensed_g, eaten_g, bowl_g}` and nothing else.
3. **Facts are arithmetic; words are optional.** The LLM never calculates — it only phrases
   facts that code already computed. If the LLM is down, the facts still reach you.
4. **An alert needs a verdict, and a verdict needs a full day.** Nothing says "they ate
   little" before the evening.

---

## Build steps — paste each block into Claude Code, in order

### Step 1 — the adapter and the daily log

```text
Build the data layer for a pet-feeder stats tool in ~/agent/pets (Node 20+, no framework).
Config comes from ~/agent/pets/config.json: {"ha_url": "...", "token_file": "...",
"unit_g": 10, "pets": ["a","b"], "tz": "Europe/London"} — the token is read from a file
(0600) or the OS secret store, never inline.
- adapter.js: the ONLY file that knows Home Assistant entity names. Export three async
  functions, each returning plain numbers, over HA's REST API:
    dispensedEvents(dateISO) → [{ts, grams}]  — read the feeder's "food out / dispensed"
       EVENT entity history. History endpoint: GET /api/history/period/<start>?
       filter_entity_id=<id>&end_time=<end>&significant_changes_only=0 — you MUST pass
       end_time explicitly (without it HA returns only 24 h from start, and for a past day
       that is often an empty list). The event's time is its entity STATE (an ISO timestamp),
       not last_changed.
    eatenTotal(dateISO) → grams — read the feeder's "day eaten" EVENT entity: take the
       row whose attribute marks the daily TOTAL, not the per-meal rows.
    bowlNow() → grams currently ON THE BOWL SCALE (a sensor).
  Multiply device "units" by config.unit_g (a dispenser unit is NOT necessarily 5 g or 1 g —
  one config value, with a comment saying how to verify it, see trap 2).
  For the test: ship adapter.stub.js that serves the same three functions from a small
  fixtures file (3 weeks of plausible data), selectable with --stub, because no feeder is
  attached here.
- collector.js: run once per invocation (a scheduler will call it every 15 min). For today:
  call the adapter, then merge into log.json as {"2026-10-12": {"dispensed_g":.., "eaten_g":..,
  "sessions": n, "bowl_samples": [[iso, g], ...], "partial": false}}. Rules: today's entry
  is OVERWRITTEN with the latest totals (never summed — a re-run must not double count);
  a past day is never rewritten; bowl_samples keep at most 1 per 15 min; the very first
  day the collector ever ran is stored with "partial": true. Write via temp file + rename.
- `collector.js --backfill 14` fills the previous N days from the adapter once.
```
**Check:** run `node collector.js --stub --backfill 14` then `node collector.js --stub`
three times: `log.json` has 15 days, today's totals are identical after the second and third
run, the oldest-run day is marked `partial`.

### Step 2 — the facts (arithmetic only)

```text
Create ~/agent/pets/facts.js: a PURE function facts(log, profile, now) → JSON, no network,
no LLM, and a CLI `node facts.js --stub` that prints it. profile.json holds per-pet
{name, weight_kg, species, life_stage}. Compute, ignoring any day with "partial": true:
- avg_eaten_7d vs avg_eaten_prior_7d, and the % change (null if either window has <4 days).
- recommended range per day for the group: resting energy RER = 70 × kg^0.75 kcal per pet;
  multiply by 1.2–1.6 (adult neutered 1.2, growing 2.0 — make it a table in code); turn
  kcal to grams with config.kcal_per_100g. Return {low_g, high_g}. If a weight is missing,
  say "no range" instead of guessing.
- days_below_range / days_above_range in the last 7 days.
- leftover: median bowl-scale grams at 22:00 over the last 7 days.
- peak_hours: the two hours of the day with most feeder sessions in 14 days.
- the share of dispensed food eaten within 60 min of the meal (from bowl_samples).
- weight trend from profile weights if ≥2 readings in 30 days.
- Every fact carries "ok": false with a reason when it lacks data (never null-padded
  numbers). Add unit tests with a fake log: a steady week, a 25 % drop, a feeder-install day.
```
**Check:** `node --test` passes; with the stub fixtures `node facts.js --stub` prints a
sensible trend, and deleting the weights from `profile.json` turns the range into
`"no range"` rather than a made-up number.

### Step 3 — alerts that only fire when something is wrong

```text
Create ~/agent/pets/watch.js, run every 15 min (the scheduler calls it after collector.js).
Read facts + the device state from adapter.js and apply these rules, using ONLY code:
- EATING: after 21:00 local time, if today's eaten_g < 50% of the MEDIAN of the last 7
  non-partial days, and there are at least 3 such days → alert once per day. Before 21:00
  never judge; before 3 logged days never judge ("learning" in last_reason).
- FILTER/DESICCANT: these are 30-day COUNTDOWNS, not sensors. Alert when <=3 days remain.
  Provide `node watch.js --reset filter` which presses the reset button via the adapter and
  then reads the meter back (>=90 %) before it reports success.
- WATER: the fountain "no water" status is IGNORED while the filter counter is 0 — it
  false-alarms without a filter. Resumes when the filter is reset.
- Placeholder guard: if any sensor reports the SAME exact value on every event over 7
  days, mark it "placeholder" in a findings list and never use it in a message.
- Every alert goes through one function send(key, text): at most one message per key per
  day, state stored in watch-state.json BEFORE sending (a crash must not repeat it), and
  ~/agent/notify.sh is called with the text. Under --dry or --stub, print instead of send.
- watch.js writes last_reason ("eating: 14:05, too early to judge") into watch-state.json
  so "why was it silent?" always has an answer. Add --at <iso> to run against a fake clock.
```
**Check:** with `--stub --at 2026-10-12T14:00` and a low-eating fixture: silent, reason
"too early to judge". Same fixture at `T21:30`: exactly one alert; run again: none
(already sent today). With the filter fixture at 0 and a "no water" status: no water alert.

### Step 4 — the weekly note (the LLM may only phrase)

```text
Create ~/agent/pets/weekly.js: build the facts JSON (facts.js), then ask an LLM to write a
4-line note from it — headline, two observations, one suggestion — in the owner's language.
Rules for the prompt: use ONLY numbers present in the JSON; never invent a cause; say
"no data" for any fact with ok:false; no medical diagnosis — a suggestion may be "weigh
them again" or "mention it to the vet if it continues", never a condition. After the model
answers, VALIDATE in code: every number in the note must appear in the facts JSON (±rounding);
if any does not, discard the note and send the plain facts as bullet lines instead.
Keep the LLM call behind one function llm(prompt) so it can be swapped; with --stub it
returns a canned reply (one good, one with an invented number — the second must be rejected).
Send the result through notify.sh once, and save it to note.json with a generated_at date.
```
**Check:** `node weekly.js --stub --dry` prints the note; with the bad canned reply it prints
the plain-facts fallback and says why. `note.json` gets a fresh `generated_at`.

### Step 5 — the schedule and the wall page (written, not run)

```text
Write me the scheduler entries: collector.js and watch.js every 15 minutes, weekly.js on
Sunday 09:15, as launchd plists (macOS) or systemd timers (Linux), each logging one line per
run to ~/agent/pets/run.log. Then a single static page.html (no framework) that reads
log.json and note.json from a tiny local server and shows: today's eaten vs recommended
range, a 14-day bar chart (eaten vs dispensed), and the weekly note. Use the same colour for
the same series everywhere.
```
**Check:** `run.log` gains a line every 15 minutes; the page loads on a phone on your Wi-Fi.

---

## The traps we hit (read these twice)

**1. The bowl scale is not "eaten".** For a week we logged the day's *maximum bowl-scale
reading* as "eaten today" — the entity was even named "eaten food". It is the grams in the
bowl right now; it rises at every meal and drifts down as they eat. One day our log said 159 g
and the real figure was 53 g. *Fix:* take eaten from the feeder's own daily-total event, keep
the scale only for "what is left", and cross-check one day against the vendor app before you
trust any of it.

**2. "One unit" is not 5 g.** We multiplied the dispenser's units by 5 for weeks because the
schedule screen looked like it said so. The device's own "food out complete" event (9 units →
"90") showed it was 10. *Fix:* one `unit_g` value in config, and verify it once by dispensing
a known amount onto a kitchen scale.

**3. History queries without an end time quietly return nothing.** Home Assistant's history
endpoint defaults the window to 24 hours from the start, so asking for last Tuesday returns
an empty list that looks like "no data". *Fix:* always pass an explicit end time, and treat
an empty history for a day with a known meal as an error, not a zero.

**4. The event's date is its state, not its `last_changed`.** The first row of a history
query is a snapshot clamped to the query start, and a Home Assistant restart re-records old
events — so grouping by `last_changed` put yesterday's meals on today. *Fix:* group by the
timestamp the event carries as its state.

**5. The platform forgets; your log does not.** Home Assistant's recorder keeps days, and
the chart we wanted needed a month. *Fix:* the collector copies each day's totals into a file
you own every 15 minutes. Overwrite today's entry, never add to it, so a re-run cannot double
count.

**6. The install day poisons every average.** The first, partial day of data pulled the
"leftover" and trend maths down for weeks. *Fix:* flag the first collected day `partial` and
skip it in every fact.

**7. We judged breakfast at noon.** The first eating alert compared half a day's food with a
whole day's target and cried "a bit low" at lunch. *Fix:* no verdict before 21:00, and no
verdict before three logged days of baseline. The same rule keeps a brand-new install quiet.

**8. A filter meter is a countdown, not a sensor.** After we swapped the filter the page still
said 0 % for days: the device cannot sense a new filter, it only restarts its 30-day count when
you press reset. *Fix:* an explicit "I replaced it" action that presses the reset and reads
the meter back before reporting success.

**9. A fountain with no filter "has no water".** With the filter counter at 0 the water sensor
reported "waterless" while the tank was full, and the alert fired every day. *Fix:* ignore the
water alert while the filter counter is 0; it resumes by itself after a reset.

**10. Some numbers are placeholders.** One of our litter-box sensors reported the same fixed
value on every visit — it cannot measure that at all. If a number never changes, it is a label,
not a measurement. *Fix:* detect "identical on every event for a week", mark it
`placeholder`, and never put it in a message.

**11. Two pets, one bowl.** The feeder cannot tell who ate. We split the total evenly and say
so on the page; per-pet eating needs a per-pet source (a microchip feeder or a camera), not a
cleverer formula. Weight, on the other hand, can be per pet: a few single weigh-ins were
noise (half in the scale, a hop), so keep only readings within 20 % of the last known weight.

**12. The model wrote a number nobody gave it.** In early weekly notes the LLM "rounded" a 14%
drop to "about a fifth". *Fix:* the prompt says use only numbers in the JSON, and the code
rejects any note containing a number that is not in the facts — falling back to plain facts
instead of sending a nice-sounding guess.

**13. The number-checker rejected a good note.** Our first test run threw away a perfectly honest
weekly note because it said "within 60 minutes" and "at 22:00" — numbers that are *settings*,
not facts, so they were not in the JSON. A strict check that rejects true notes teaches you to
switch it off. *Fix:* put the constants the note may mention (the meal window, the leftover
hour, the week it covers) into the facts payload, and ignore list markers and clock times when
comparing.

**14. A guard that has no input must say so, not guess.** When the filter counter simply is not
exposed by a feeder, "ignore the water alert while the filter is 0" has nothing to read. Decide
that case on purpose (we let the water alert through and log "filter not exposed") rather than
letting it fall out of an undefined comparison.

---

## You know it works when

- [ ] `log.json` has a row per day, and re-running the collector never changes a past day.
- [ ] One day's `eaten_g` matches the vendor app to within a few grams.
- [ ] A kitchen-scale test confirms `unit_g`.
- [ ] A low-eating test day fires one alert after 21:00 and nothing before.
- [ ] A swapped filter goes back to 100 % only after your "I replaced it" action.
- [ ] A weekly note containing an invented number is replaced by the plain facts.
- [ ] `last_reason` explains every silence.

## Extend it

- **Per-pet eating.** Add a camera or microchip feeder as a second source in the adapter.
- **Refill ahead of time.** From grams dispensed per day, tell the shopping list when the bag
  runs out in ~10 days.
- **Weight follow-up.** When the trend crosses ±5 % in a month, ask the owner to weigh the pet
  once — the cheapest sensor is a person with a kitchen scale.
- **Feed from chat.** A "feed 20 g" command that asks for confirmation first — it dispenses
  real food.

## Changelog

- 2026-10-13 — first public version. Tested on the versions in the header.

---

*Part of **Build It Yourself** — free briefs on building a personal AI Chief of Staff, one
capability at a time: https://chief.natalicot.com/briefs. Free, forever. · License CC BY 4.0*
