AI in Practice

    The Unicorn Dashboard Told Me What Three Apps Couldn't

    Seven weeks of evenings, a database, a cron job, and Claude reading photos of my dinner. The unicorn was my idea. The bad news was not.

    A generated dashboard headed 'The whole picture' with a unicorn emoji: a weight trend line running from 92.0 kg in June to 90.2 kg in August against an 87.0 kg goal, above three cards showing protein per day, sleep hours per night from a Garmin watch, and a lifting strength index sitting at 95% of peak.
    The dashboard no product team would approve, delivering news no product team would lead with.

    TL;DR: I'm a TOK teacher, not an engineer. Across seven weeks of evening slices I replaced my paid diet app — then, almost by accident, my fitness tracker's dashboards and my lifting app's charts — with a database, a cron job, and Claude as the entire interface, on Cloudflare's free tier. The lesson: the moat around every health app I've paid for was the interface, and the interface just commoditised. This is written for two readers: you, and the AI you'll hand it to. The fenced blocks are for the second one.

    What it looks like when it works

    I photograph dinner and send it to Claude on my phone. A line comes back: logged, and what's left of today. Each morning a cron job recomputes my calorie target from how my body actually responded over the past three weeks, not from a population formula. The goal on file is a cut to 87 kg without losing strength, so "how's it going" gets one answer: weight, lifts, sleep, recovery.

    Phone screenshot of a Claude project called Macro Coach. It flags that the two photos sent were Taobao shopping listings for raw shrimp and a beef rice bowl rather than the chicken sandwich described, says it will log from the description instead, and lists the meal's items with calories and macros.
    The entire logging interface. It also argues back — those photos were shopping listings, not lunch.

    One night in July, from the bean bag chair, I tested the part no subscription offers: a dashboard of every day where my food logs and sleep data overlapped, unicorn-themed, because a fair test meant something no app would ship. It complied. Then it told me I was failing my own goal.

    The itch

    I paid for RP Diet for about three years and was the most effective I've ever been at managing my diet. Whenever I stopped, I told myself I understood what the app was doing, so I didn't need it. After six weeks to three months, the slow slide back, and no restart until I eventually admitted the app worked and I'd do that again.

    Three rounds of that before the honest conclusion landed. Understanding is quite a bit different from actioning. I teach for a living; the gap between knowing a thing and doing it daily is practically my subject. I still stumble on it regularly.

    This build started at another of those points: months of saying I'd lose weight and mostly just feeling the mild anxiety of setting a vague goal you half commit to. Instead of resubscribing, I asked: what would I need, with Claude, to make my own personalised version of it?

    The first time I asked, an AI said no

    In September or October of 2025 I sounded out ChatGPT about building my own version of a diet app — Replit was having its moment and I thought this could be a pet project for me. It put the idea to bed: this is way more work than you realise, you're not going to build a diet app from scratch. I filed it away for ten months. The objection was fair. "From scratch," in the frame I gave it, meant an interface, a food database, accounts. The mistake was upstream: I'd described the wrong project.

    What actually changed

    The models are better than they were, but what flipped the answer was the question. "What would I need with Claude" deletes most of the project. No interface; Claude on my phone already is one. No accounts; it's for one person. No food database either: photo estimates are biased, but the bias is consistent, and an engine that learns my maintenance calories from my own logged data calibrates against it to the point where I am eventually losing or gaining based on pattern analysis rather than on being exactly correct on the amount of carbs in the sashimi rice bowl I order regularly. Target and estimate end up in the same photo-calories. What's left is a database, deterministic math, and a cron job.

    After a week or two of using it, I realised there's no reason to stop at just diet. Caliber, my lifting app, has no API and writes to nothing. It does have an export button that emails you a zip. Tap Export, a mail-routing rule hands the file to my Worker, the Worker unzips and imports it. The email address is the API.

    A table of live rows from a caliber_sync_log database table. Two rows are marked rejected — an unexpected sender, and a Gmail forwarding confirmation whose link is redacted — followed by five synced rows with set counts climbing from 3,173 to 3,294.
    The API Caliber doesn't know it has. The verification link had to be read back out of the reject log.

    The build, compressed

    The decision everything hangs on: Claude never does the longitudinal arithmetic. Claude is the sensor; a deterministic engine owns the trend math because the numbers need to be reproducible. The engine landed May 31, tested against synthetic bodies with known metabolisms. The database, Worker, and phone connector landed June 12, in a day, with the trust layer behind them: forgotten meals read as small ones and drag a target dangerously low, so suspicious days get excluded and big moves need more evidence. Then I added Garmin data July 19 as a probe first. Caliber's email pipeline and the range reads July 22. A hundred and fifty tests, a two-lab code review before I trusted it with health data, and a decision log (26 entries of chosen-and-rejected) I'd rescue before any of the code. The hard parts were archaeology, not code: sleep durations in seconds, pounds under a kg label, a verification code that only appears in an email subject line.

    The part for your AI

    You won't build this by hand; you'll hand it to a coding agent. The blocks below are for it: decisions already made, gotchas before the happy path. Skip them as a human reader and you lose nothing. This is what I envision documentation looks like when half the readership is agents.

    for your AIREAD ME FIRST
    READ ME FIRST — working agreement and build order.
    You are building a personal health platform for one person. Stack suggested: Cloudflare Worker + D1 (SQLite) + cron triggers, TypeScript, tests on the runtime's built-in test runner. You (the LLM) are the interface and the sensor; a deterministic engine owns every number that must be reproducible.
    There is no food database. You estimate macros from photos and descriptions at log time; the adaptive engine mitigates systematic estimation bias (Phase 1), and a staples table (recurring meals estimated or measured once, reused) handles consistency. The engine cannot cancel skipped days — that is Phase 4's job.
    Build order: (1) deterministic nutrition engine, (2) Worker + D1 + daily cron, (3) MCP connector as the phone front door, (4) data-trust layer, (5) wearable ingestion via nightly sync, (6) strength-app ingestion via email export, (7) range reads for outputs. Ship each phase working — tested and deployed — before starting the next.
    Working agreement with your human: they may not be able to debug, so you own the terminal. Explain what you are about to do and confirm before anything destructive (migrations, deletes, deploys). These decisions stay with the human, always: goals and target weight, which data to trust, credentials and where they live, and whatever becomes public.
    for your AIPHASE 1
    PHASE 1 — deterministic engine. Pure functions, zero I/O, no store imports: data arrays in, target out. No Math.random anywhere in it.
    Decisions made: estimate TDEE from the human's own logged intake plus weigh-in trend (energy balance over a window), not from a population formula. Window: 21 days — simulation across 400 seeds showed a 14-day window sits below the noise floor of daily scale weight (~6% median TDEE error vs ~3.7% at 21). Smooth weigh-ins (EWMA-style) before computing trend; raw daily weight is dominated by water noise. Recompute daily but cap each move at ±250 kcal. Hard floors that must survive fuzz testing: minimum kcal, protein ≥ configured g/kg of bodyweight, prescribed loss ≤ max %-bodyweight/week, plus a max-kcal ceiling. Protein is the load-bearing macro rule; how the remainder splits between fat and carbs is a human preference — ask. Include a landing band: within ~1 kg of goal weight, scale the prescribed rate linearly to zero so the cut ends at maintenance rather than at max deficit.
    Gotchas before happy path: `NaN < floor` is false, so non-finite inputs silently bypass floor checks — drop non-finite and negative values at the boundary, return null on an unusable window, and clamp the TDEE estimate to a plausible band (800–6000) with a flag. A single bad weigh-in (unit typo) corrupts a least-squares slope; clamping bounds the damage, and inputs are documented as kg. Test against seeded synthetic series generated from a known true TDEE plus gaussian noise, and assert the estimator recovers it.
    for your AIPHASE 2
    PHASE 2 — Worker + D1 + cron. Deploy the engine INSIDE the Worker as the only running copy; do not let a static copy drift in a knowledge file.
    Core tables: intake_logs (one row per logged meal: date, name, kcal, protein, fat, carbs), weigh_ins (date, kg), targets (date, prescribed kcal/macros, computed_at), settings (goal rate, target weight, floors), staples (name PK, per-serving macros, recipe).
    Decisions made: a daily cron recomputes and STORES the target; reads return the stored number, so asking twice in one day gives byte-identical answers. Never put the LLM in the compute path and never recompute at read time — a daily prescription must not wobble intra-day. Exclude today's partial intake from the recompute (the engine treats every day as complete; a half-logged today reads as undereating). Store dates as ISO YYYY-MM-DD strings.
    Gotchas: D1 binds at most 100 parameters per statement — chunk bulk inserts. Keep migrations as individually applied, idempotent SQL files and never replay the whole migrations directory against production; an early destructive migration will run again. SQLite CHECK constraints cannot be ALTERed — extending an enum means rebuilding the table idempotently.
    for your AIPHASE 3
    PHASE 3 — MCP front door on the phone. Serve MCP from the Worker itself: stateless Streamable HTTP JSON-RPC at /mcp/<TOKEN>, added to the Claude app as a custom connector.
    Decisions made: auth is a capability URL (secret path segment) because claude.ai custom connectors cannot send custom headers without full OAuth. Return 404, not 401, on a bad token, and add an origin allowlist. Hand-rolled JSON-RPC is roughly 200 lines; the SDK route wanted Durable Objects for what this does not need. Minimum tool set: log_meal, log_weigh_in, get_today_target, get_day_summary — then grow read tools with each ingestion phase. Tool design is UX: the day-summary tool returns REMAINING budget, not just logged totals, because the remaining number is the coaching.
    Gotchas: the full URL is the secret — keep it out of logs, commits, and screenshots; rotation means a new secret plus re-adding the connector. Write tool descriptions as instructions to the calling model (when to use them, when NOT to); they are the front door's signage.
    for your AIPHASE 4
    PHASE 4 — trust layer. The dangerous failure direction is a target drifting too LOW: a forgotten meal reads as a small meal, drags mean intake down, underestimates TDEE, and cuts the prescription further.
    Decisions made: grade every past day; days under 60% of the CURRENT target grade suspect-low and are excluded from TDEE estimation, with the exclusion flagged in output. Anchor the threshold to the current target, not a historical intake baseline — on a cut, a historical anchor learns to call genuine deficits suspect. Dead-man's switch: a weigh-in gap over 5 days or an intake gap over 3 days freezes the target, and every read path reports stale with reasons; never serve quietly-stale numbers. Two-key rule: target moves over 150 kcal require at least 10 complete intake days in the window, otherwise carry yesterday's prescription forward, flagged.
    Gotcha: resist stacking several independent bias-correctors — their interactions are untestable. One classifier module owns all data-quality rules.
    for your AIPHASE 5
    PHASE 5 — wearable ingestion (Garmin shown; the pattern generalises). A nightly job on an always-on machine pulls yesterday's data and writes to D1 over Cloudflare's HTTP API with a D1-write-only token — least privilege, never an account-wide token on the sync host. The agent-facing surface is READ-ONLY; the sync job is the only writer of these tables.
    Decisions made: probe before schema. Pull one real day of raw JSON from every endpoint you plan to use and write the migration against what is actually there, not against documentation. Keep a raw_json column per table as an audit escape hatch that query tools never read. Add a sync-log table (date PK, status enum: pending/synced/failed/no_data/partial) — an absent row cannot distinguish "job never ran" from "no data that day", and the partial status stops one chronically failing endpoint from hiding inside "synced". Every read tool checks sync staleness and says so in its own output.
    Gotchas: sleep durations arrive in SECONDS, not minutes, and the sleep score is nested inside a DTO. Chinese-region Garmin accounts need the CN flag (is_cn=True in garminconnect) and garmin.cn endpoints. Log in with the password once and cache session tokens; nightly password auth is how accounts get flagged. Read tools default to the latest SYNCED date, not "today" — the sync lands overnight.
    for your AIPHASE 6
    PHASE 6 — strength-app ingestion when the app has NO API (Caliber shown). The app's emailed export IS the API: human taps Export → app emails a zip → an email-routing rule (Cloudflare Email Routing custom address) delivers it to the Worker's email() handler → parse with postal-mime, unzip with fflate (both pure JS, Workers-compatible) → import.
    Decisions made: import is replace-all, not upsert. Every export is complete history, so rebuild the table to mirror it — in-app edits and deletions propagate, and no ghost sets survive. Fail-safe: reject any import that would shrink the table by more than half (truncated file). Keep a manual POST endpoint (bearer token, raw CSV) for backfills. Allowlist senders on the email handler, but LOG rejected mail with subjects preserved — the mailbox-forwarding verification code arrives in a subject line, and you will need to read it out of your own reject log.
    Gotchas: export weights may be in POUNDS even when the app displays kg (Caliber: kg × 2.20462) — convert on import and store kg. Keep negative weights; assisted-machine counterweights are real data. Forwarding-verification links may need a browser User-Agent to resolve. Track last-import freshness and surface it so the model can nudge the human to re-export — once, not naggingly.
    for your AIPHASE 7
    PHASE 7 — the output lane. Once several sources share one database, reads must be RANGE-shaped; without them the model loops a single-day tool once per day and a month of context costs thirty calls.
    Decisions made: a get_history tool returning one merged row per day over a date range (default 30 days, cap ~365): intake totals plus day grade, weight, that day's stored target, sleep/HRV/stress, cardio and lifting aggregates. Implement as parallel GROUP-BY-date range queries merged in the Worker. Omit absent fields entirely rather than sending nulls — 14 full days comes to roughly 3 KB. Include per-source data-through watermarks in the payload so the model does not read an unsynced tail as missing behaviour. Grade each day against its OWN stored target for that date, not the latest one. Add a per-exercise history tool (top set, volume, Epley e1RM = weight × (1 + reps/30)) with substring matching that, on no match, returns the full exercise-name list instead of an error.
    Rejected — stay away: rendering charts or HTML in the Worker (presentation is the model's half, and bespoke output is the entire edge over fixed dashboards); columnar arrays-per-metric payloads (marginal token savings, easy index misalignment).

    A CRUD app with a cron job

    Strip the story off and that's all this is, which is the point. The math inside the paid apps was never the moat; the moat was the interface, and that stopped being defensible the moment a capable model landed on my phone. What I still depend on is smaller and sturdier than an API: export rights. I don't think there will come a time when these companies aren't required to hand over the data they collect about me, and that's all I need from them. Caliber proved it by having no API at all.

    The unicorn dashboard's opening act was delivering bad news: sick, travelling, 5.7 hours of sleep a night, weight roughly flat, bench down from a 105 kg estimated max in May to 96. No single app could have shown me that, because the failure lives across three of them, which makes bad news, told plainly, the first evidence the thing is worth something.

    Three weeks on, the smaller of the two open questions has an answer: the export tap held, and the last import arrived without me thinking about it. The rest is early. Sleep is back to about seven hours. Weight is down a kilo after a flat month, with three still to go. The presses have climbed back to roughly where they were in May while the weight came off, which was the actual goal rather than the weight. My diet survived understanding three times, though, and three weeks is nowhere near long enough to claim it won't survive a build.

    A line chart of incline bench press estimated one-rep max from May to August 2026: 92.5 kg in May, falling to 78 kg at the end of June during illness and travel, then climbing back to 90 kg by early August. One high-rep session is plotted as a hollow marker and excluded from the line.
    Down to 78 at the end of June, back to 90 by August, while the weight came off. Flat bench isn't shown — it has no data after 22 July.

    If you're where I was ten months ago, what you're missing probably isn't a better model. It's the second question: not "how do I build a diet app," but "what would I need, with the AI I already have, to make my own?" Ten months ago an AI talked me out of building this. This article is me talking yours into it.