Your body, talking to Claude, unprompted.
health is a Claude Code channel plugin that streams your WHOOP recovery,
sleep, and strain into Claude the moment your body produces them. A local
daemon archives every record your WHOOP account has ever generated, decides
what is worth saying, and pushes events into your live Claude Code session as
<channel> messages. Claude reads them as your coach: number first, drivers,
one actionable call.
WHOOP cloud ──(poll + webhooks)──> healthd (your Mac)
├─ SQLite archive (yours, forever)
├─ decision engine (quiet by design)
└─> <channel> events ──> your Claude Code session
WHOOP band ──(BLE Broadcast HR)──> health-relay ──(WS, raw frames)──^
- Morning recovery brief: score, band, HRV and RHR vs your 7-day baseline, sleep folded in, one actionable read. Delivered the moment you wake: WHOOP scores your sleep ~2 minutes after you get up, and that event ends quiet hours early (wake release; togglable).
- Workout cards when activities score (strain, HR, zones, calories).
- Early-warning vitals alerts, gated on multi-day patterns (never single-day dips; alert fatigue is treated as the #1 failure mode).
- Strain crossings, bedtime nudges, trend alerts: each togglable, all quiet by default.
- A data-freshness watchdog: if no poll has succeeded for 90 minutes (whatever the cause: burned token, network, WHOOP outage), the daemon raises one loud alert per broken day instead of failing silently.
- Live heart rate (optional): turn on the band's Broadcast Heart Rate and
the bundled BLE relayer streams it into the daemon in real time. You get
/health live(current BPM, zone, live resting HRV when still), automatic session detection (WHOOP cannot signal workout starts; a sustained-hot heart rate can), zone milestones during the session, and an end-of-session summary with the HR-recovery read. Zones are Karvonen (% of heart-rate reserve over a rolling 7-day median resting HR), matching WHOOP's own bands. Detection is confidence-tiered: HR level alone cannot separate a warmup from a hot shower, so a session only earns coach attention once it shows an exercise signature (set/interval structure, sustained depth, or your declared intent); an unconfirmed elevation is demoted at the end instead of counting as training load, then upgraded automatically if WHOOP later scores an overlapping workout. - Band yield (Strava handoff): the relayers hold the band's broadcast
exclusively, and a held band never advertises, so other apps can never pair
it as a sensor. One tap in the phone app (or
/health yield) disarms every relayer so Strava can take the broadcast for a recording; reclaim with a tap, a command, or window expiry. Indefinite yields never auto-reclaim (a daily reminder nags instead), yields survive daemon and relayer restarts, and a relayer that misses its disarm is re-pushed and surfaced loudly. Live coaching is dark while yielded, by contract; WHOOP's own recording and scoring are unaffected. /health: today's read on demand./health trendfor the long view.- A permanent local archive of your WHOOP data in one SQLite file.
- Not affiliated with, endorsed by, or supported by WHOOP. Unofficial.
- Not a medical device and not medical advice.
- Not a cloud service. There is no server, no telemetry, no analytics. Your health data never leaves your machine.
- macOS (Keychain and launchd are load-bearing), bun
- An active WHOOP membership (any paid tier) and a WHOOP 4.0/5.0/MG
- Your own free WHOOP developer app (single-tenant by design: your credentials, your data, your machine)
- Claude Code with channel support
/plugin install health@s0nderlabsThen run setup from the plugin directory:
bun run setupSetup walks you through: registering your WHOOP dev app, storing credentials in the macOS Keychain (never on disk), the one-time OAuth consent, backfilling your full history, and installing the always-on launchd daemon. Re-running it is always safe and is also the recovery path: it validates the stored tokens and only asks for a fresh consent if WHOOP has actually rejected them.
Polling alone gives a few minutes of lag. For push latency (~30 s from the WHOOP cloud), expose the local receiver over HTTPS, for example with Tailscale Funnel:
tailscale funnel --bg 8789Register https://<your-funnel-host>/whoop as a webhook URL (Model Version
v2) in your WHOOP dashboard. The receiver verifies every request's HMAC
signature against your client secret; everything else is rejected.
If your rides are recorded by a bike computer and uploaded to a training
platform, the daemon can watch for each upload, announce it into your
sessions the moment it lands (closing the "did my ride sync yet" loop), and
give the activity a real title and description in place of the generic
auto-name. Claude composes the text in-session from your training plan and
the ride data; a deterministic plan-derived fallback covers the case where
no session is live. Strict guards: only generic auto-names are ever
replaced, only an empty description is filled, and anything you rename by
hand is left alone forever. Bring your own API app and run
bun run setup:strava (consent + webhook subscription) to enable it.
The same watcher also covers the lift cards WHOOP itself pushes to the platform. WHOOP's auto-description publishes your strain score on every one; the daemon strips that line seconds after upload, no session needed, and announces the card so Claude can title it and write a real training note after you debrief the session (the card alone carries no sets or loads, so composition deliberately waits for your report). A weekday fallback names anything no session gets to.
Enable Broadcast Heart Rate in the WHOOP app, then build and install the BLE relayer (macOS, near your body):
scripts/build-relayer.sh
bin/health-relay # or install it under launchd for always-onIf you run it under launchd, copy the binary somewhere OUTSIDE ~/Documents
(e.g. ~/.local/bin) and point the LaunchAgent there: Documents is
TCC-protected, and a launchd spawn of a rebuilt binary from a protected
folder wedges silently in dyld with no way to prompt.
The relayer is a dumb pipe: it subscribes to the band's standard Bluetooth
Heart Rate service (matching device names against WHOOP by default; set
HEALTH_RELAY_DEVICE to override) and forwards every notification raw to the
daemon's loopback WebSocket (live.port, token-authenticated; the token is
generated into your config on first daemon start and never leaves the
machine). All parsing, session detection, zone logic, and HRV math happen in
the daemon, behind physiological artifact gates: optical wrist HR under
vibration can broadcast an impossible value (a double-count reads as ~2x your
true rate), and those frames are rejected before they can touch session
stats, counted in the live status, and logged with their raw bytes. Notes: the band normally streams to one receiver (a second can
only join by racing the brief post-drop advertising window; the daemon
orchestrates that at home), and broadcasting costs band battery, so many
people run it only during training.
If the packets carry RR intervals (WHOOP 5.0 does), /health live includes a
rolling resting rMSSD when you are still enough. macOS quirk: each rebuild of
the unsigned relayer binary re-triggers the Bluetooth permission check when
it runs under launchd; re-grant it in System Settings > Privacy & Security >
Bluetooth if the log stalls before "scanning".
relayer-ios/ is a SwiftUI app that takes over as the band's receiver
whenever you are away from the Mac (gym, outside), speaking the same
raw-frame protocol over a tailnet-only tailscale serve HTTPS endpoint. At
home the daemon deliberately holds the band on BOTH receivers when it can
(the band accepts a second central when two connects race its advertising
window): the Mac writes the live record, the phone rides along as a hot
standby and takes over with zero gap if the Mac drops. Away, the phone is
the sole holder and is never interrupted: the daemon only orchestrates the
dual hold when both receivers have recently proven they can reach the band
(wifi alone is not trusted as a home signal), and never on cellular or low
battery.
Handover is silent in both directions: a parked phone keeps a pending BLE
connect armed, so walking out of the Mac's range hands it the band without
opening the app. The app adds: a Plan tab rendering the training
plan your main session's coach writes to plan.json (tap-to-expand ladders
where every working, back-off, and bodyweight set is its own checkmark;
checking a set starts its prescribed rest, with an audible chime when rest
ends: in-app it beats the mute switch and ducks your music, locked it rides
the rest-over notification). Double days split into a Ride / Lift toggle;
the ride face is a read-only viewer (steps, cues, an HR readout in words,
an optional route preview from a GPX the daemon serves out of its routes/
dir, an optional fuelling clock) that never touches the band, so a head
unit can hold it on the road. A sideload clock reads the embedded
provisioning profile and counts down to the free-team signature expiry
(in-app, a notification the day before, and up to the daemon so
/health status reports the real date),
a one-tap workout intent that lands in your live Claude session (while a
session runs, the start button becomes a live session bar with an elapsed
timer and an in-app End, mirrored in the Plan header), a
lock-screen Live Activity (live BPM pulse card; during sessions a timer +
next-set card with a rest countdown and end button), and a HealthKit courier
that streams WHOOP-sourced step counts into the daily read. Build and
sideload with a free Apple ID (7-day re-sign; one-time Xcode signing setup):
scripts/build-phone.sh install # phone paired + on the same Wi-FiThe app never depends on DNS to find the daemon. iCloud Private Relay (and
any encrypted-DNS app) resolves names outside Tailscale's MagicDNS, and a
ts.net name with Funnel enabled also has public records, so a plain lookup
can land on the wrong address. Instead, every daemon request goes through a
small HTTP CONNECT tunnel on the phone's loopback interface that splices
raw TCP to the Mac's tailnet IP, while URLSession keeps speaking TLS end to
end to the ts.net name (normal SNI and certificate checks). The app
learns the tailnet IP by asking Tailscale's own resolver (100.100.100.100)
directly and keeps it only after a /healthz check through the tunnel
passes; it re-learns on foreground and after repeated socket failures.
Settings has a Find button and accepts a typed IP (tailscale ip -4 on
the Mac). Clearing the field by hand switches back to the plain hostname
path.
Sessions started with the health channel receive the event stream; every
session with the plugin loaded gets the tools (/health, config, status).
The channel is the opt-in:
claude --channels=plugin:health@s0nderlabsEvents queue durably and redeliver until some channel session acknowledges one (an ack marks it done for all). Un-acked events retry across session drops and reconnects; per-class expiry eventually retires anything nobody was around to see (a three-day-old bedtime nudge helps no one).
Talk to it (/health config) or edit ~/.config/health/config.json:
event class toggles, thresholds, quiet hours, daily event budget, poll
interval, webhook port/path, live-ingest port/max-HR/session threshold.
- Tokens and client secret: macOS Keychain only. WHOOP rotates refresh tokens on every use; the daemon persists the new one before anything can touch it.
- Archive:
~/.claude/channels/health/health.db(SQLite). Back it up; it is your health history. A human-readabledaily-log.md(one line per day) sits beside it, regenerated from the archive as a durable memory anchor. - Webhook receiver binds 127.0.0.1 only; public exposure is your tunnel's job, and every request is HMAC-verified.
- The plugin's read scope is exactly WHOOP's six read scopes. It can write nothing to your WHOOP account.
Apache-2.0. (c) s0nderlabs.