Your Fitness Agent Needs Reliable Data Before Better Advice
$ grep -n "^##" 2026-10-fitness-agent-needs-reliable-data.md
A fitness assistant should establish which observations are current and comparable before using them to explain your day.
In The Reverse Tamagotchi, I described the plumbing behind a personal assistant: WHOOP and Strava data landing in a Google Sheet. The next question is what a row in that sheet actually means.
A row can exist while its recovery score is pending. Two records can describe the same exercise. A date can identify the night before the day you intended to discuss. Before improving the prompt, I would inspect those joins and missing-data paths.
Start with the source’s idea of a day
WHOOP organises its data around physiological cycles, which need not align with calendar days. A current cycle may have a start time without an end. Treating its date as the key for everything else throws away that distinction before the assistant sees it.
The current integration builds one Daily-sheet row per cycle. Recovery joins by cycle_id. Sleep also joins by cycle ID, preferring a non-nap record and then the later start among records of the same kind. A sleep without a cycle ID is skipped by this join.
Cycle identity determines which records belong together. The local date labels the result; it does not determine the join.
Those are explicit selection rules. They do not prove the selected sleep is always the exact sleep referenced by the recovery record, which has its own sleep_id. That relationship is worth checking if a later analysis depends on a particular sleep rather than the cycle as a whole.
The calendar label is a separate decision. Consider this synthetic example, in Singapore time:
| Observation | Timestamp |
|---|---|
| Cycle starts | 27 September, 23:30 |
| Sleep ends | 28 September, 07:00 |
| Intended active-day label | 28 September |
Truncating the cycle’s UTC start timestamp produces 27 September. That is a valid date for the start, but the wrong label for this morning’s briefing.
The mapper retains that old UTC-derived date for compatibility and supplies a separate date_local. For the local active day, it uses sleep end, then cycle end, then cycle start plus twelve hours. The last fallback is a heuristic for an ongoing cycle. It cannot resolve every travel itinerary, shift pattern or unusual sleep schedule.
Keep the cycle identity and original timestamps even when a friendly daily label is useful. A label should help display a record, not silently redefine which observations belong together.
A missing score is a state
WHOOP’s recovery schema distinguishes SCORED, PENDING_SCORE and UNSCORABLE. The score object is present only for a scored recovery. An unscorable recovery can reflect insufficient metric data for the interval.
For the assistant, these are different situations:
- Pending: a record exists, but the provider has not produced its score.
- Unscorable: the provider could not score that record.
- No matching record: the join or fetch supplied nothing for the requested interval.
- Fetch failed: the integration could not establish what the provider currently holds.
None means a recovery score of zero.
The current Daily mapper carries scoring-state fields and maps absent scores to null. The recovery tool exposed to the bot likewise returns a score state and nullable metrics. That gives a consumer information it can preserve instead of manufacturing a number.
Google Sheets skips null inputs; setting a cell to an empty value requires an empty string. The current update path sends the mapper’s values unchanged. When updating an existing row, an absent score mapped to null can therefore leave the previous cell value in place. Appending a fresh row has no previous score to preserve. This is a risk in the implementation, not an observed incident. Missingness needs to survive the complete journey, including what the assistant eventually reads.
My rule would be to withhold an interpretation that requires a missing observation, while allowing a precise account of the observations available. “Recovery is still pending” is useful information. Turning it into “you recovered poorly” adds a claim the source never made.
Existing rows can be stale
The API types include source created_at and updated_at timestamps. The Daily-sheet projection does not carry those update timestamps, and the bot’s recovery output omits them too. A consumer can therefore see a score without being told when its source record last changed.
The sync paths also do different jobs. WHOOP forwarding refetches a recent window—72 hours by default—and inserts or updates matching cycles. The Strava reconciliation route defaults to 25 hours and processes activity IDs missing from the sheet. That latter operation fills gaps; it does not refresh every existing activity. WHOOP deletion events currently leave Daily rows intact.
Calling both operations “sync” does not make their completeness guarantees equivalent. A row’s presence answers whether something was written, not whether later changes or deletions have been reconciled.
WHOOP recommends reconciliation because notifications can be missed or duplicated. A bounded lookback is useful, but a correction outside its window needs another path. The assistant should not infer complete history from a recent successful webhook.
Source identity matters across providers too. If WHOOP and Strava both describe one workout, two rows are not automatically two workouts. Matching those representations needs an explicit rule before summing training volume. Preserve units as well: sleep-stage milliseconds and activity seconds are a poor place to rely on a model guessing correctly.
Give the assistant the gaps
I would extend the input contract to retain source identity, observation interval, source update time, retrieval time and scoring state. That is a proposed improvement beyond the current projections, not an already implemented freshness guarantee.
This synthetic record illustrates the shape, without personal measurements:
provider: whoop
cycle_id: example-cycle
timezone: Asia/Singapore
cycle_start: "2026-09-27T23:30:00+08:00"
cycle_end: null
active_date: "2026-09-28"
active_date_basis: sleep_end
recovery:
score_state: PENDING_SCORE
score: null
source_updated_at: "2026-09-28T07:05:00+08:00"
fetched_at: "2026-09-28T07:10:00+08:00"
gaps:
- recovery_not_scored
Here, retrieval time says when the integration checked. Source update time says when the provider changed the record. Neither says a pending score has become available. An open cycle is also not a finished observation interval.
Freshness requirements should follow the question. A historical weekly summary and a briefing about this morning need different checks. Define the required inputs and acceptable age for each, rather than declaring every row universally fresh or stale.
For this example, the assistant can say: “The recovery record was checked at 07:10 Singapore time and is still pending. I cannot compare today’s recovery score with earlier scores yet.” That statement preserves what the system knows and identifies what another fetch might resolve.
Clean data would still not validate a wearable’s measurements or establish that generated training advice is medically sound. The narrower engineering job is achievable: preserve the source’s meaning, carry uncertainty through the pipeline, and make the assistant state the missing observation before reasoning past it.
$ subscribe --newsletter
Practical AI engineering, in your inbox
Field notes for technical leaders building agents, evaluation systems, governance, and production infrastructure.
Related
When Should an Agent Ask Permission?
A useful agent acts within delegated authority, prepares consequential actions for review, and asks again when the action changes.
The 5-Step Loop: Why Your Agent Fails at Step 4
ReAct gave us a three-step loop. Production hardened it into five. The two new steps — Plan and Verify — are where everything that goes wrong, goes wrong. And the field has now named the worst offender.
What Belongs in Agent Memory
A durable handoff separates observations, hypotheses and unfinished work, then records what would make each observation stale.