A diet and lifestyle assistant my wife and I use through Telegram. You type what you ate or send a photo of the plate, and it returns calories and macros. It tracks weight and water against a daily target and starts a conversation on its own each morning and evening.
There is no app to install and no dashboard to open. Everything happens in a chat window that was already on our phones, and the whole record lives on a machine I control.
The bot drives most of the interaction. It asks for a weight in the morning, nudges about water during waking hours, takes meal logs whenever you send them, and closes the day with a summary of what you ate against your target.
A log starts as a plain message. The text is parsed into individual food items with quantities, calories and macros, and comes back as a draft you can act on. Saving is a deliberate step, so an estimate you disagree with never becomes a record.
Photos work the same way, with one difference. Calorie estimates from a single image are biased low and get worse as the portion grows, so the app applies a correction before showing the draft, and asks for a one line note when the picture is a drink or a mixed dish where the estimate would be a guess.
Weight is handled separately. You send a number, and the app reports an exponential moving average over your recent weigh-ins. Day to day weight moves too much with water, salt and time of day to be worth reading on its own.
The code is split into a delivery layer and a core. The delivery layer knows about Telegram: message handlers, the allowlist that limits the bot to two users, and the scheduled jobs that send the proactive messages. The core knows nothing about it.
That boundary is the design decision I care most about. Nutrition, metrics, storage and the use-cases in core/ are forbidden from importing the delivery layer, and a test walks the imports and fails if the rule is broken. It means the half of the app worth keeping is portable: moving off Telegram would replace the top two boxes and leave everything below untouched.
The model provider sits behind an interface for the same reason. Gemini is the default because the free tier covers two users, and swapping it is a single implementation, not a rewrite.
Every model call returns a typed object validated against a schema. Nothing parses free text, so a malformed answer fails loudly instead of writing nonsense. Each task also has a small set of labelled examples it is scored against, which is what lets me change a prompt and see immediately whether the estimates got worse.
Between the model and the database sit two guards. A plausibility check drops items with impossible calorie counts, and the item count is capped so one bad parse cannot write unbounded rows. The transport layer routes through the guarded function rather than the raw parser, so the checks cannot be bypassed by a different entry point.
It runs as a managed service under launchd or systemd and restarts on failure. Startup fails on a broken database instead of half working, an unhandled error in one message is caught and answered rather than killing the polling loop, and a nightly script takes a snapshot that is safe to run while the bot is still writing, keeping the last fourteen.
Running daily for two users. Text and photo logging, weight and water tracking, daily summaries and scheduled check-ins are working, with 119 tests over the core. Meal suggestions are next.