1. The collection philosophy
NataCoach is not a quantified-self product. Whoop and Garmin already are; we do not compete with their dashboards, we consume their conclusions. Health Sync exists for exactly one purpose: to let @NataCoachBot make the same call Nata would make if she saw the client walk into the gym — "you look wrecked, we're going lighter today" — without asking the client anything.
Three consequences of that stance:
- Decisions first, metrics second. Every row in the data inventory names the coaching decision it improves. No decision, no collection. This keeps the pipeline small, the privacy surface small, and the Wiki Brain free of noise.
- The wearable is the default input; the user is the fallback, not the other way around. Manual logging is a last resort that costs the user taps. Per the prime directive (the user does less), a question to the user is a budgeted expense: at most one recovery-related question per day, and only when it changes a decision.
- Everything lands as an event. Whether a fact arrives via webhook, poll, or button tap, it becomes an immutable event in Postgres (and
raw/in the user's wiki repo), then a normalized metric, then — nightly — distilled context in the Wiki Brain. Analytics never read LLM output; they read events (locked decision 6 in the brief).
2. The connect flow
Connecting a wearable is a first-session onboarding step (see 02-user-experience.md) and takes the user under 60 seconds. The bot sends a deep link; the provider handles consent; our callback binds the provider account to the Telegram user; then we backfill 30 days and switch to webhooks.
sequenceDiagram participant M as Marta (Telegram) participant B as NataCoachBot participant HS as Health Sync service participant P as Whoop OAuth + API M->>B: taps "Connect Whoop" button B->>HS: create connect intent (telegram_user_id, provider) HS-->>B: signed deep link (state token, 15 min TTL, single use) B->>M: message with "Authorize Whoop" URL button M->>P: opens link, logs in, grants scopes P->>HS: redirect to /oauth/callback with code and state HS->>HS: verify state, bind provider account to Telegram user HS->>P: exchange code for tokens P-->>HS: access token (1 h) + rotating refresh token HS-->>B: connection confirmed B->>M: "Whoop connected. Pulling your last 30 days now..." HS->>P: backfill jobs via BullMQ (sleep, recovery, workouts, 30 days) P-->>HS: paged historic data HS->>HS: normalize to events, then daily_metrics P->>HS: ongoing webhooks (recovery.updated, sleep.updated, workout.updated) HS->>P: fetch full object by id (thin webhook pattern) B->>M: next morning, first recovery-aware Morning Brief
Design details that matter:
- The state token is a signed JWT carrying
telegram_user_id,provider, and a nonce; 15-minute TTL, single use. This is the entire account-linking mechanism — no passwords, no email matching. If the token is expired the callback page shows one line ("Link expired — tap Connect again in Telegram") rather than an error dump. - Tokens are stored encrypted (AES-256-GCM, key held outside the DB) in a
provider_accountstable:user_id, provider, provider_user_id, access_token_enc, refresh_token_enc, scopes, status (active | needs_reauth | revoked), last_event_at. Privacy treatment is detailed in 03-system-architecture.md. - Backfill is 30 days, not 90. Thirty days is enough to establish the user's recovery baseline and weekly load rhythm for the weight-proposal engine; more history adds API cost and privacy weight without changing a decision. Backfill runs as a BullMQ job chain, ~120 paged requests per user, completes in under 5 minutes at pilot scale.
- The bot narrates the outcome, not the plumbing. After backfill: "Got it — 30 days of sleep and recovery in. Your average night is 6 h 41 m and your recovery baseline is 58%. Tomorrow's Morning Brief will use this." The numbers make the connection feel immediately worth it.
3. Whoop API v2
Whoop is the launch-priority provider (Marta wears one) because it hands us the exact abstraction we want: a daily recovery score already normalized to 0–100.
| Aspect | What we use |
|---|---|
| Auth | OAuth 2.0 authorization-code flow; scopes read:recovery read:sleep read:workout read:cycles read:body_measurement offline (offline grants the refresh token) |
| Token lifetime | Access ~1 h; rotating refresh token — every refresh returns a new one, so refresh handling must be transactional (a lost rotated token = forced re-auth) |
| Collections pulled | Recovery (score 0–100, HRV RMSSD ms, resting HR, skin-temp deviation — we ingest only the first three), Sleep (duration, stage breakdown: light/deep/REM/awake, sleep performance %), Cycle (day strain, 0–21 scale), Workout (auto-detected activities with strain, avg/max HR, duration) |
| Webhook vs poll | Webhooks primary. Whoop v2 sends thin webhooks (recovery.updated, sleep.updated, workout.updated) containing only the object id + user id, HMAC-signed; we verify the signature, then fetch the full object. Poll as safety net: a 30-minute reconciliation sweep for any user whose last_event_at is stale on a day we expect data. |
| Rate limits | Plausible assumption: ~100 requests/min and ~10,000/day per app. Pilot load (10 users) is ≪1% of that; backfill bursts are queue-throttled to 60 req/min anyway. |
| Update semantics | Whoop recomputes: a recovery score can be revised after a nap, a sleep record after the user edits it in the Whoop app. Webhooks re-fire; we version the event and take latest-wins (see §5.3). |
4. Garmin Health API
Garmin covers Denys (Forerunner) and is architecturally the easier integration: the Health API is push-based — Garmin POSTs summary payloads to our endpoint, no polling loop needed.
| Aspect | What we use |
|---|---|
| Auth | OAuth via Garmin Connect consent; connect flow identical in shape to §2 |
| Push summary types | Dailies (steps, resting HR, intensity minutes, Body Battery), Sleeps (duration, stages, overnight HRV on supported devices, sleep score 0–100), Activities (runs/rides/strength sessions with HR, duration, training load), Body Composition (weight, body-fat % if the user owns a Garmin Index scale) |
| Backfill | Historic backfill is requested per time range and delivered asynchronously through the same push endpoint — same 30-day window as Whoop |
| Recovery signal | Garmin has no single recovery score. We synthesize one from Body Battery at wake (0–100) — see the mapping in §5.2 |
⚠ Project risk: Garmin partner program
The Garmin Health API is not self-serve. Access requires an application to Garmin's developer/partner program (company details, use-case description, data-handling answers), and approval historically takes 2–6 weeks with no SLA. This is a real schedule risk flagged in 11-roadmap.md.
Mitigation ladder, in order:
- Apply on day one of the build, before any Garmin code is written; Whoop (self-serve) ships first regardless.
- Interim fallback — conversational mode. A Garmin user without API access still gets full coaching: the one-question morning fallback (§7) replaces the recovery feed. Denys loses automatic run import, not coaching quality.
- Interim fallback — manual export. Power users can export activities from Garmin Connect (FIT/CSV) and forward the file to the bot; Health Sync parses it as a batch of activity events. Documented but not promoted — it violates the user does less.
- Phase-2 structural fallback — Apple Health bridge. Garmin syncs to Apple Health on iPhone; the phase-2 bridge (e.g. Health Auto Export pushing JSON to our webhook, per the brief locked decision) reaches Garmin data without Garmin's program. Denys is an iPhone user; this path fully covers him if approval stalls.
5. Normalization
5.1 Canonical daily metrics
Every provider payload is reduced to one canonical row per user per local day (materialized from events into daily_metrics). Coaching logic and analytics read only this table plus the event log — never provider-specific fields.
| Canonical field | Type / unit | Range |
|---|---|---|
sleep_duration_min |
integer, minutes | 0–960 |
sleep_quality |
integer | 0–100 |
recovery_score |
integer | 0–100 |
resting_hr |
integer, bpm | 30–120 |
hrv_ms |
integer, RMSSD ms | 10–200 |
day_load |
integer | 0–100 |
steps |
integer | 0–100,000 |
weight_kg |
decimal(4,1) | 30.0–250.0 |
Each field carries per-field provenance (source, source_event_id, is_proxy) so a proxy value from a button tap is never mistaken for a measured one.
5.2 Provider mapping
| Canonical | Whoop | Garmin | Conversational fallback |
|---|---|---|---|
sleep_duration_min |
Sleep activity total sleep time | Sleep summary duration | Parsed from "about 6 hours" (proxy) |
sleep_quality |
Sleep performance % (already 0–100) | Sleep score (already 0–100) | great = 85, ok = 65, rough = 35 (proxy) |
recovery_score |
Recovery score, taken as-is | Body Battery at wake, taken as-is (0–100) | great = 80, ok = 55, rough = 30 (proxy) |
resting_hr |
Recovery resting_heart_rate |
Dailies resting HR | — (never asked) |
hrv_ms |
Recovery hrv_rmssd_milli |
Overnight avg HRV (supported devices only) | — (never asked) |
day_load |
round(strain / 21 × 100) |
min(100, round(intensity_minutes / 1.5)) (assumption: 150 weekly-guideline minutes in one day ≈ ceiling) |
Session Mode RPE-weighted volume (see 06) |
steps |
— (Whoop does not report steps) | Dailies steps | — (never asked) |
weight_kg |
Body measurement (static, rarely updates) | Body Composition push (Index scale) | Weekly weigh-in propose-confirm (primary source, §7) |
An honest caveat, encoded in the system rather than hidden: Whoop's recovery (HRV-driven) and Garmin's Body Battery (energy model) are not the same physiology, so cross-provider comparison of raw scores is banned. What is canonical is the band: green ≥ 67, yellow 34–66, red ≤ 33 (Whoop's own bands, adopted product-wide). All coaching decisions key off the band; the raw score appears only in trends within one provider. The Coach Console shows Marta and Denys in bands, never in falsely comparable percentages.
5.3 Conflict resolution
Conflicts are rare at pilot (each persona wears exactly one device) but the rules are fixed now:
| # | Situation | Rule |
|---|---|---|
| 1 | Two providers report the same metric same day | Static priority per metric: weight: confirmed weigh-in > Garmin scale > Whoop profile. Recovery/sleep/HRV/RHR: Whoop > Garmin. Steps/activities: Garmin > Whoop. |
| 2 | Device vs user statement, measured quantity (sleep duration, HR) | Device wins. "Felt like 5 hours" is stored separately as a perceived-sleep note in the Wiki Brain — a Marta-style gap between felt and measured sleep is itself coaching signal. |
| 3 | Device vs user statement, self-referential quantity (weight on the user's scale, soreness, mood, RPE) | User wins, always. |
| 4 | Provider revises a value (Whoop recompute after a nap) | Latest wins in daily_metrics; both versions stay in the event log. Decisions already made are not retro-changed — if the Morning Brief went out on recovery 31% and Whoop later revises to 44%, today's plan stands, and the revision is noted in the wiki's log.md. |
| 5 | Proxy vs measured, same day (fallback answer at 07:00, webhook lands at 09:00) | Measured overwrites proxy for the metric; the decision made on the proxy stands (rule 4). |
6. How recovery data changes coaching — same day
This is the payoff for the whole pipeline. Recovery data feeds exactly two same-day decisions:
1. The Morning Brief — tone and plan proposal are chosen by band:
| Band | Morning Brief stance | Session Mode effect |
|---|---|---|
| Green (≥ 67) | "Recovery 82% — green light." Confirms today's plan as programmed. | Weight proposals follow the progression plan, including due increases (e.g. +2.5 kg on squat). |
| Yellow (34–66) | "Middling recovery (52%). We keep today's weights, no increases — deal?" | Proposals hold at last session's loads; progression deferred; optional "trim one set" offer. |
| Red (≤ 33) | Proposes a change, propose-confirm as always: cut volume ~20% or swap to mobility/zone-2. | Proposals recomputed at reduced volume; the weight-proposal engine receives recovery_band=red as an input alongside history. |
2. Session Mode weight proposals — the engine in 06-training-experience.md takes (exercise history, last RPEs, recovery_band, day_load trend) and emits per-set proposals. Recovery is an input, never an override: the user can always tap through to the original plan.
Realistic red-day transcript (Marta, baby night, Whoop recovery 31%):
07:12 @NataCoachBot
Morning, Marta ☀️
Rough night — 4 h 50 m of sleep, recovery 31% (red).
Today is Pull Day. Proposal: same exercises, one set
less each, and we hold last week's weights instead of
adding. About 35 min instead of 45.
[ ✓ Do the lighter version ] [ Keep full plan ] [ Skip today ]
07:14 Marta
✓ Do the lighter version
07:14 @NataCoachBot
Smart call. Lat pulldown will open at 38 kg (not 40).
See you at the gym — tap Start when you're there. 💪
Timing contract (identical in 02 §2.2 and 03 §2): the Brief is built by a per-user pre-dawn job (06:30 local) and delivered 30–45 minutes after detected wake, falling back to the user's chosen time (default 07:15 local) when no wake signal has arrived — never inside quiet hours. Sleep/recovery webhooks usually land 20–60 min after wake; the readiness check below decides whether the Brief is data-driven or question-driven.
7. Failure UX
Health Sync fails quietly and degrades to conversation — the user never sees an error they cannot fix in one tap.
flowchart TD
A["Morning Brief scheduler fires (user local time)"] --> B{"Fresh sleep or recovery data for today?"}
B -- "yes" --> C["Compute recovery band"]
B -- "no" --> D["Poll provider once (reconciliation sweep)"]
D --> E{"Data arrived?"}
E -- "yes" --> C
E -- "no" --> F["Ask one question instead"]
F --> G["Map answer to proxy values (is_proxy = true)"]
C --> H["Send recovery-aware Morning Brief"]
G --> HToken expiry / revocation. A failed refresh (invalid_grant) flips the account to needs_reauth and triggers exactly one message with a fresh deep link:
@NataCoachBot
Your Whoop link expired (they rotate these for security —
not your fault). One tap fixes it:
[ 🔗 Reconnect Whoop ]
No nagging: if ignored, the reconnect button is silently appended to the next Morning Brief for up to 5 days, after which the bot switches to conversational mode and Nata sees a "Whoop disconnected 5+ days" flag in the Coach Console roster.
Provider outage. Three consecutive failed fetches (or webhook silence past the expected window plus a failed poll) opens a circuit breaker for that provider. The Morning Brief still ships on time — it just opens with one question:
@NataCoachBot
Morning! Whoop is having a slow day, so quick check —
how did you sleep?
[ 😴 Great ] [ 🙂 OK ] [ 😩 Rough ]
One tap yields proxy sleep_quality/recovery_score (mapping in §5.2), the day proceeds normally, and when the provider recovers, backfill fills the gap: measured values replace proxies in daily_metrics, but per rule 4 the decisions already taken stand. The user experiences an outage as one extra tap, which is the entire point.
8. Conversational capture as a data source
The chat itself is Health Sync's third provider — the highest-trust one for subjective data. Everything captured conversationally becomes an event, exactly like a webhook payload.
| Capture | Mechanics | Budget / trigger | Event type |
|---|---|---|---|
| RPE taps | After the final set of each exercise in Session Mode: [ 🟢 easy ] [ 🟡 solid ] [ 🟠 hard ] [ 🔴 maxed ] (maps to RPE 6 / 7 / 8 / 9–10 — canonical scale in 06 §3) |
Once per exercise — one tap; per-set RPE is a chore lifters skip | rpe.recorded |
| Soreness | Single question, only pre-workout and only if today's session loads a muscle group trained < 48 h ago or last session's RPEs ran hot | ≤ 1× per day, only when it changes the warm-up or load proposal | checkin.soreness |
| Mood / energy | Piggybacked on an existing message, never standalone; asked only on red-band days without wearable data, or when the Wiki Brain flags a 3-day engagement dip | ≤ 1× per day, decision-gated | checkin.mood |
| Weekly weigh-in | Propose-confirm: "Friday weigh-in! Last week 91.2 kg, trend says ~90.9. What does the scale say?" → [ ✓ 90.9 ] or type a number |
1× per week, user-chosen day | weight.confirmed |
| Perceived sleep | Free-text remarks ("barely slept") parsed opportunistically; never solicited when device data exists | Passive | note.perceived_sleep |
Denys, Friday morning, travel week:
07:05 @NataCoachBot
Friday weigh-in 📉 Last week: 91.2 kg. Trend says ~90.9.
What does the hotel scale say?
[ ✓ 90.9 ] [ ✏️ Enter number ] [ No scale here — skip ]
07:07 Denys
✏️ 91.4
07:07 @NataCoachBot
91.4 logged. Up 0.2 on the week — travel weeks usually
read +0.5 from water and restaurant sodium, so the trend
line still points down. Nothing to fix. ✈️
The "skip" option is load-bearing: a skipped travel weigh-in generates a weight.skipped event, which teaches the Weekly Review to interpolate rather than nag — and tells the Wiki Brain that Denys's data goes quiet in hotels, which is itself part of his model.
9. Data inventory
The complete list. Column five enforces the rule: if it does not change a coaching decision, we do not collect it.
| Datum | Source | Cadence | Lands where (event → metric → wiki) | Decision it improves |
|---|---|---|---|---|
| Recovery score | Whoop webhook / Garmin Body Battery | Daily, on wake | health.recovery → recovery_score → wiki/recovery-patterns.md |
Morning Brief stance; Session Mode load multiplier |
| HRV (RMSSD) | Whoop / Garmin (supported devices) | Daily | health.recovery → hrv_ms → wiki/recovery-patterns.md (trend) |
Detects sustained overreach → Nata alert in Coach Console |
| Resting HR | Whoop / Garmin dailies | Daily | health.recovery → resting_hr → wiki/recovery-patterns.md (trend) |
Overtraining/illness early warning (7-day drift > +5 bpm flags Nata) |
| Sleep duration | Whoop / Garmin sleep | Daily | health.sleep → sleep_duration_min → wiki/recovery-patterns.md |
Morning Brief; red-band volume cuts |
| Sleep stages (deep/REM) | Whoop / Garmin sleep | Daily | health.sleep → event only (no daily metric) → Weekly Review trend |
Trend-only, never same-day: low deep-sleep trend prompts a Nata habit conversation |
| Sleep quality score | Whoop performance % / Garmin sleep score | Daily | health.sleep → sleep_quality → wiki/recovery-patterns.md |
Fallback recovery proxy weighting |
| Day strain / load | Whoop cycle / Garmin intensity minutes | Daily | health.strain → day_load → wiki/training-history.md |
Weekly volume planning; rest-day proposals |
| Auto-detected workouts | Whoop / Garmin activities | Per activity | activity.imported → event → wiki/training-history.md |
Counts un-logged cardio (Denys's zone-2 runs) toward the week without asking |
| Steps | Garmin dailies | Daily | health.daily → steps → Weekly Review |
NEAT context for Denys's fat-loss energy balance in Meal Lens feedback |
| Weight | Weekly weigh-in > Garmin scale | Weekly | weight.confirmed → weight_kg → wiki/goals.md (trend) |
Program-goal tracking; Meal Lens calorie-target adjustments |
| RPE per exercise | Session Mode taps | Per exercise | rpe.recorded → event → wiki/exercises/*.md |
Next-session weight proposal per exercise (06) |
| Soreness | Decision-gated question | ≤ 1/day | checkin.soreness → event → wiki/training-history.md |
Same-session warm-up and load adjustment |
| Mood / energy | Decision-gated question | ≤ 1/day | checkin.mood → event → wiki/profile.md (patterns) |
Tone selection; engagement-dip escalation to Nata |
| Meal photos + macros | Meal Lens | Per meal | meal.scored → event → wiki/nutrition-patterns.md |
Food-program adherence feedback (owned by doc 05) |
| Form videos | Form Check | Ad hoc | video.reviewed → event → wiki/exercises/*.md |
Technique cues; injury-risk escalation (owned by doc 07) |
| Pain / injury mentions | Any message (keyword + classifier) | Passive | safety.flagged → immediate escalation → wiki/constraints-injuries.md |
Pauses programming; always escalates to Nata (locked decision 7) |
Deliberately not collected (each fails the rule at pilot scale): SpO₂, respiratory rate, skin temperature, stress scores, GPS routes, continuous heart-rate streams, wearable calorie-burn estimates (misleading enough to be actively harmful next to Meal Lens numbers), VO₂max, and menstrual-cycle data (would genuinely change coaching for Marta, but the privacy weight demands its own consent design — revisit in phase 2, noted in 11-roadmap.md).
Every inventory row flows the same pipeline: event (Postgres + raw/) → normalized metric (daily_metrics) → nightly distillation into the Wiki Brain — one architecture, regardless of whether the fact arrived from a Whoop webhook or a thumb tap.