PTENES
MODULE 2.4

⌚ Wearable and scheduling

Connect WHOOP end to end — OAuth, callback, daily sync to the table vitals — with both production gotchas, and schedule the sync and morning check-in.

7
Topics
~35
Minutes
Intermediate
Level
Practical
Type
Your progress in this module 0% · 0 of 7
1

🔗 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:

1

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.

2

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.

3

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.

🔗 WHOOP appid + scopes ↩️ /whoop/callbackyou authorize it 1× 🔑 refresh tokensaves to ~/.env 🔄 whoop-sync.pydaily cron 🗄️ vitalsrecovery, hrv, rhr, sleep on every run, the sync writes a new refresh token over the previous one (rotation)

📊 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

WHOOP App

Give HealthOS the id + secret.

Redirect URI

.../whoop/callback, exactly.

Scopes

recovery + sleep + cycles.

offline

Releases the refresh token.

2

✅ 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.

1

You click "Authorize"

Opens the WHOOP consent screen with the scopes you selected. You accept.

2

WHOOP redirects

It sends you to https://<seu-host>/whoop/callback with a one-time code.

3

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

One-time

Authorize once.

Callback

Exchanges code for tokens.

Refresh token

Saved in ~/.env.

No browser

Sync takes care of itself afterward.

3

🔄 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

recovery_pct

% of overnight recovery.

hrv_ms

Heart rate variability (ms).

resting_hr

Resting frequency.

sleep_hours

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

Pulls recovery + sleep

The night WHOOP scored.

Record in vitals

4 fields per day.

Deterministic

Same day, same output.

Idempotent

Running it again doesn’t create duplicates.

4

🛡️ 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_grant and 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/urllib delivers the game → 1010. Sending a browser header fixes it.

Key concepts

Rotation

Save the new token on every run.

invalid_grant

Symptom of a non-rotated token.

User-agent

Pretending to be a browser gets past Cloudflare.

Error 1010

Cloudflare banned the client.

5

⏰ 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.

⏰ cron scheduler 🔄 sync · 07:00whoop-sync.py 🔄 sync · 10:00whoop-sync.py 🔄 sync · 13:00whoop-sync.py 🌅 /checkin · 07:00agent turn 🗄️ vitals 🤖 review on Telegramreads the recovery

📊 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

7 / 10 / 13

Fetches recovery when it comes through.

crontab (Linux)

One line schedules the 3 times.

launchd (macOS)

.plist.example ready in the repo.

Repeating is free

Idempotency protects.

6

🌅 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

/checkin

The morning review prompt.

Agent turn

An LLM call, not a script.

Sync first

Recovery on the table when it triggers.

Scheduler

Cron or your agent's scheduler.

7

🔁 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→ mappingvitals; 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

Reusable pattern

OAuth + daily sync.

vitals is the contract

The coach reads the table, not the API.

Manual works

Message becomes a row.

Universal gotchas

Rotation + antibot on every source.

📋 Module summary

✓
App + redirect + scopes — create the app, log .../whoop/callback exact and mark read:recovery read:sleep read:cycles offline.
✓
Authorizes once — the callback writes the WHOOP_REFRESH_TOKEN in the ~/.env.
✓
whoop-sync.py → vitals — deterministic and idempotent; writes recovery_pct, hrv_ms, resting_hr, sleep_hours.
✓
The 2 gotchas — rotate the refresh token (otherwise invalid_grant) and send a browser user agent (otherwise Cloudflare 1010).
✓
Schedule sync + check-in — sync at 7/10/13 and /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.