🔗 WHOOP OAuth: create the app
For the coach to read your WHOOP, you first create a developer app in the WHOOP portal. This app gives HealthOS an identity (client id + secret) and tells WHOOP exactly where to send you after you authorize. Three decisions define everything: the redirect URI, the scopes e o offline.
🟢 New here? What is OAuth
OAuth is the standard way to “grant permission without handing over your password.” Instead of entering your WHOOP login in HealthOS, you click Authorize on WHOOP’s website and it returns a token — a temporary key that only lets you read what you’ve granted access to. The redirect URI is the address WHOOP sends you back to with this token.
In the developer portal, three steps:
Create the app
In the WHOOP developer portal, create an app. You'll receive a WHOOP_CLIENT_ID and a WHOOP_CLIENT_SECRET — go to the ~/.env.
Register the redirect URI
Paste exactly https://<seu-host>/whoop/callback. It has to match character for character what your callback uses—or WHOOP will reject the authorization.
Enable the scopes
Mark read:recovery read:sleep read:cycles offline. The three read: unlock the data; the offline unlocks the refresh token (sync without you nearby).
📋 Paste into the dashboard · goal: the WHOOP app's two exact fields
Redirect URI: https://<seu-host>/whoop/callback
Scopes: read:recovery read:sleep read:cycles offline
How to verify: the dashboard saves without complaint and the offline appears marked. Without the offline you’d have to reauthorize every day — automatic sync wouldn’t run.
📊 How to read: from left to right is the path from permission to data—the app authorizes in the callback, the callback saves the token, and the sync uses the token and writes to vitals. The dashed line back is the rotation of the token (gotcha 1 in topic 4): each run replaces the token with the next one.
Key concepts
Give HealthOS the id + secret.
.../whoop/callback, exactly.
recovery + sleep + cycles.
Releases the refresh token.
✅ One-time authorization
You authorize WHOOP only once. With this authorization, your callback receives the code, exchanges it for tokens, and saves the WHOOP_REFRESH_TOKEN in the ~/.env. From there, the daily sync runs on its own with this refresh token—you don’t need to open the browser again.
You click "Authorize"
Opens the WHOOP consent screen with the scopes you selected. You accept.
WHOOP redirects
It sends you to https://<seu-host>/whoop/callback with a one-time code.
The callback saves the token
The callback exchanges the code for tokens and writes the WHOOP_REFRESH_TOKEN in the ~/.env. Done: the one-time step is over.
▶ Run it yourself · goal: check that the refresh token was saved
grep WHOOP_REFRESH_TOKEN ~/.env
How to verify: a line appears WHOOP_REFRESH_TOKEN=... with a long (non-empty) value. If it comes back empty, authorization didn't complete — repeat the "Authorize" step.
💾 What went in the ~/.env
After the one-time setup, your ~/.env has all three: WHOOP_CLIENT_ID, WHOOP_CLIENT_SECRET (from the app, in Topic 1) and now the WHOOP_REFRESH_TOKEN (recorded by the callback, rotated at each sync). This file lives in your home directory and never goes to git.
Key concepts
Authorize once.
Exchanges code for tokens.
Saved in ~/.env.
Sync takes care of itself afterward.
🔄 whoop-sync.py
O agent/scripts/whoop-sync.py is the heart of the connection. Each time it runs, it fetches WHOOP recovery and sleep data and writes four fields to the table vitals: recovery_pct, hrv_ms, resting_hr, sleep_hours. It is deterministic (same day, same result) and idempotent (running it again doesn’t create a duplicate row — it updates that day’s row).
🟢 New here?
- Deterministic — there’s no magic or randomness involved: given the same day in WHOOP, the output is always the same. You can trust it and rerun it.
- Idempotent — running it 1× or 5× leaves the database in the same state: one row per day in
vitals, not five. That’s why scheduling repetitions is safe.
🗄️ The 4 fields that go into vitals
% of overnight recovery.
Heart rate variability (ms).
Resting frequency.
Hours of sleep last night.
▶ Run it yourself · goal: sync now and see the 4 fields in vitals
python3 agent/scripts/whoop-sync.py
python3 agent/scripts/db.py select vitals
How to verify: today's line in vitals brings recovery_pct, hrv_ms, resting_hr e sleep_hours filled in. Run both commands again: it still a row for the day (idempotent), not two.
Key concepts
The night WHOOP scored.
4 fields per day.
Same day, same output.
Running it again doesn’t create duplicates.
🛡️ The 2 production gotchas
Two details will break the sync if you don’t handle them—and both only show up in production, after the first sync has already worked. The whoop-sync.py already handles both; understand why so you don't stumble when rewriting it.
✗ If you ignore
- ✗(a) Reuse the same refresh token → on the 2nd run, WHOOP responds
invalid_grantand sync dies. - ✗(b) Using Python’s default user-agent → WHOOP’s Cloudflare bans you with error 1010.
✓ What the sync does
- ✓(a) On each run, it records the new refresh token that WHOOP returns in place of the old one (rotation).
- ✓(b) Sends a browser user agent in the requests, so Cloudflare lets them through.
⚠️ How each failure appears
- •Gotcha (a) — rotation: the refresh token belongs to one-time use. When you use it, WHOOP returns a new one and invalidates the old one. If you don’t save the new one, the next run uses a token that’s already been burned →
invalid_grant. Verify by running the sync twice: the 2nd one should still succeed. - •Gotcha (b) — user-agent: Cloudflare in front of the API blocks “robotic” clients. The default user agent of the
requests/urllibdelivers the game → 1010. Sending a browser header fixes it.
Key concepts
Save the new token on every run.
Symptom of a non-rotated token.
Pretending to be a browser gets past Cloudflare.
Cloudflare banned the client.
⏰ Schedule the sync
WHOOP doesn’t score your night at a fixed time — sometimes at 7 a.m., sometimes later. That’s why the sync runs three times in the morning (7 a.m., 10 a.m., 1 p.m.): one of these runs will pick up the recovery as soon as it’s ready. Since the sync is idempotent, repeating it is free and safe.
📊 How to read: on the left, the cron triggers four things in the morning—three syncs (blue: 7 a.m./10 a.m./1 p.m.) that write to vitals, e o /checkin (amber, topic 6), which reads your recovery and sends the review on Telegram. The three-hour interval ensures it picks up the recovery as soon as WHOOP scores it.
▶ Run it yourself · goal: schedule the sync 3× in the morning (Linux/cron)
# abra o crontab: crontab -e
# sync às 7h, 10h e 13h (idempotente, repetir é seguro)
0 7,10,13 * * * cd ~/<sua-pasta>/agent && /usr/bin/python3 scripts/whoop-sync.py >> ~/whoop-sync.log 2>&1
macOS: instead of cron, use agent/setup/whoop-sync.plist.example (launchd). How to verify: the table tomorrow morning vitals gets the line for the day and ~/whoop-sync.log shows error-free runs (without invalid_grant, without 1010).
Key concepts
Fetches recovery when it comes through.
One line schedules the 3 times.
.plist.example ready in the repo.
Idempotency protects.
🌅 Schedule the morning check-in
Sync only fills the table; the one who gives you the review is morning check-in. Unlike sync (which is just a script reading the API), check-in triggers one agent run — an LLM call that reads the night’s recovery and writes the review in Telegram. That’s why it’s scheduled separately: you send the prompt /checkin every morning via your scheduler.
💡 Sync ≠ check-in
O sync is mechanical and inexpensive: reads WHOOP, records 4 numbers. The check-in is a agent turn (costs an LLM call): it reads these numbers + the rest of your snapshot and conversation with you. Schedule the sync to run before from the check-in, so the review can find the day's recovery waiting for it.
▶ Run it yourself · goal: trigger /checkin every morning (one agent turn)
# crontab -e — dispara /checkin às 7h05 (logo após o 1º sync)
5 7 * * * cd ~/<sua-pasta>/agent && /usr/bin/python3 <seu-disparador> "/checkin" >> ~/checkin.log 2>&1
Shortcut: agent platforms with a built-in scheduler (e.g., ClaudeClaw) trigger the /checkin directly, without cron. How to verify: at 7 a.m., you receive the morning review on Telegram opening with last night's recovery and tying it to yesterday's choices.
🔁 The morning sequence
1) 07:00 the sync writes the recovery to vitals. 2) 07:05 o /checkin triggers the agent, which reads that recovery and sends you the review. The 10 a.m. and 1 p.m. syncs are the safety net in case WHOOP scores the night later.
Key concepts
The morning review prompt.
An LLM call, not a script.
Recovery on the table when it triggers.
Cron or your agent's scheduler.
🔁 Other wearables
WHOOP is just the worked example. The same pattern — OAuth once + daily sync writing to vitals — works for Oura, Garmin, Fitbit, or even manual logs. The coach and dashboard don’t know or care where the number came from: they work with whatever lands in the table vitals.
✓ Reuses the pattern
- ✓Oura / Garmin / Fitbit — swapping the OAuth app and the JSON→ mapping
vitals; everything else stays the same. - ✓Manual log — a Telegram message ("slept 7h, good recovery") becomes a row in
vitals. - ✓O
/checkin, the snapshot and dashboard remain identical.
✗ What doesn’t change
- ✗Don’t rewrite the coach for each source—it reads
vitals, not the wearable API. - ✗Don’t skip the 2 gotchas: any OAuth has token rotation and bot protection in front.
- ✗You don’t need WHOOP to get started—the course works with manual logging.
✅ Self-check (optional): why does the whoop-sync.py does it need to send a browser user-agent?
Key concepts
OAuth + daily sync.
The coach reads the table, not the API.
Message becomes a row.
Rotation + antibot on every source.
📋 Module summary
.../whoop/callback exact and mark read:recovery read:sleep read:cycles offline.WHOOP_REFRESH_TOKEN in the ~/.env.invalid_grant) and send a browser user agent (otherwise Cloudflare 1010)./checkin in the morning; the pattern works with any wearable.Next module:
Track 3 — How to use it: the daily routine, food and workouts by photo, memory/patterns, and the dashboard. The step-by-step setup is done; now it's time to live with the coach day to day.