# rajanshxrma/cadence [Health: Active]

**Category:** 🏃 Sports  
**Repository:** https://github.com/rajanshxrma/cadence  
**GitHub Stars:** 1  
**npm Downloads (last month):** 3017  
**Views:** 0  
**Installs:** 0  
**Upvotes:** 0  
**Directory Page:** https://allmcps.com/mcp/rajanshxrma-cadence

## Description
Local-first health context from WHOOP and Apple Health — sleep, recovery, workouts, and personal baselines in a local SQLite store.

## Claude Desktop Quick Installation
Install path detected from listing signals. Uses `uvx` (confidence: high):

```json
"mcpServers": {
  "cadence": {
    "command": "uvx",
    "args": ["cadence"]
  }
}
```

## Documentation & README

# cadence

[![CI](https://github.com/rajanshxrma/cadence/actions/workflows/ci.yml/badge.svg)](https://github.com/rajanshxrma/cadence/actions/workflows/ci.yml)
[![Listed in awesome-mcp-servers](https://img.shields.io/badge/listed_in-awesome--mcp--servers-blue)](https://github.com/punkpeye/awesome-mcp-servers)

A local-first health-context engine. Pulls WHOOP and Apple Health into one local store, and gives Claude real, current knowledge of how you're actually doing -- sleep, recovery, strain, workouts -- instead of working blind.

![demo](https://raw.githubusercontent.com/rajanshxrma/cadence/HEAD/assets/demo.gif)

## The honest privacy story

Unlike the rest of this portfolio ([lantern](https://github.com/rajanshxrma/lantern), [stacks](https://github.com/rajanshxrma/stacks), [orchard](https://github.com/rajanshxrma/orchard)), cadence can't claim "nothing leaves the machine" -- WHOOP data necessarily comes from WHOOP's own cloud API over OAuth, there's no way around that. What's real and true instead: **once pulled, your health data lives only on this machine** (a local SQLite file at `~/.cadence/cadence.db`, permissioned `0600`, owner-only), your OAuth tokens live in the macOS Keychain (never on disk in plaintext, never in this repo), and cadence never re-uploads or re-shares anything to a third party. Apple Health data never leaves the machine at all -- it's a local file import, no network call involved. Claude reads your data locally via MCP; nothing about that transmits it anywhere else either.

## Install

```
pip install -e .
```

**Apple Health (no setup, fully local):**
```
cadence import-apple-health ~/Downloads/export.zip
cadence brief
```
(Export from iPhone: Health app -> profile icon -> Export All Health Data.)

**WHOOP (one-time OAuth setup, see below):**
```
cadence auth      # opens a browser once, stores tokens in Keychain
cadence sync      # pulls the last 30 days of recovery/sleep/workouts
cadence brief
```

**MCP server** (so Claude can query this live instead of just reading the brief):
```
cadence mcp
```
Point your Claude client at it (`claude mcp add cadence -- cadence mcp`, or the equivalent stdio-server config for your client).

## WHOOP setup (one-time)

1. Register a personal app at [developer.whoop.com](https://developer.whoop.com/) (sign in with your normal WHOOP account, create a Team if prompted, then create an App). Fill in:
   - **Privacy Policy**: a real link is required -- `https://github.com/<your-fork>/cadence#the-honest-privacy-story` (this section) is a reasonable choice for a personal-use app.
   - **Redirect URL**: must exactly match `cadence.auth.REDIRECT_URI` (`https://github.com/rajanshxrma/cadence` by default). WHOOP's own form only accepts `https://` or a custom URL scheme -- a plain `http://localhost` redirect is rejected, which is why `cadence auth` uses a copy-paste flow instead of a local callback server (see `auth.py`'s module docstring).
   - **Scopes**: check all six data scopes (`read:recovery`, `read:cycles`, `read:sleep`, `read:workout`, `read:profile`, `read:body_measurement`). There's no `offline` checkbox on the form -- that scope is requested dynamically in the actual authorization request, not a per-app setting.
   - **Webhooks**: leave empty -- cadence is pull-based (`cadence sync`), no webhook receiver.
   - Apps work immediately for up to 10 WHOOP accounts in dev status -- no approval needed for personal use.
2. Export your credentials (shown once on the app's page after creation -- copy both immediately):
   ```
   export CADENCE_WHOOP_CLIENT_ID="..."
   export CADENCE_WHOOP_CLIENT_SECRET="..."
   ```
3. Run `cadence auth`. It opens your browser; after you approve, WHOOP redirects there with a code in the URL -- copy the full address-bar URL and paste it back into the terminal when prompted. Tokens land in Keychain. `cadence sync` after that just works -- refresh happens automatically (WHOOP rotates refresh tokens on every use; cadence persists the new one each time, per their API's requirement).

Client id/secret are read from environment variables only -- never written to disk, never committed. `keyring` (the library used for token storage) needs an OS credential store; on macOS that's Keychain, no extra setup.

## Health-aware AI sessions (v0.2.0)

The piece that makes this more than a data viewer: `cadence hook` emits a [Claude Code SessionStart hook](https://docs.anthropic.com) payload, so the **first Claude session of each day opens already knowing your physiological state** -- last night's sleep, today's recovery, recent strain -- plus a one-line adaptive hint when the numbers warrant one (low recovery: "favor lighter work over marathon builds"; short sleep: flagged; data gone stale: a re-sync nudge instead of silent death). Second session of the day: silent, by design -- a hook that talks every session is noise.

```
cadence hook          # prints the SessionStart JSON (or nothing -- silence is a feature)
cadence sync --quiet  # unattended mode for a launchd/cron job; macOS-notifies if re-auth is ever needed
cadence auth --store-client   # one-time: persist client creds to Keychain so unattended sync needs no env vars
```

Wire-up: a 15-line shim script in your hooks config calls `cadence hook`; a launchd job runs `cadence sync --quiet` twice daily. The hook never blocks a session -- no network calls (local SQLite reads only), and every failure path chooses silence over a broken session startup.

## Personal baselines and anomaly detection (v0.3.0)

Every threshold below is PERSONAL, never a population norm -- cadence builds a real 30-day rolling mean/std of your own history per metric (recovery score, HRV, resting heart rate, respiratory rate, sleep duration/efficiency) and flags today's value only when it's a real statistical deviation (|z| >= 1.5) from *your* normal, not some generic chart's idea of normal.

**The honesty guardrail that matters most here:** a metric is silently omitted from baselines (not shown with a misleading small n) until there are 14+ days of real history behind it. An anomaly built on 3 data points is noise dressed up as insight -- this project would rather say nothing than say something confidently wrong.

```
cadence baseline   # prints current baselines + any deviations today
```

Anomalies surface three ways, each calibrated to how urgent they are:
- **In `brief`/the session hook** -- inline, every time they exist.
- **A focused re-alert mid-day** -- if a NEW serious deviation (|z| >= 2.5) appears after the day's first session already happened (e.g. an 8pm sync lands something the 8am one didn't have), the hook fires again just for that -- a real new signal outranks the once-daily quota. Already-seen deviations never re-fire the same day.
- **A macOS notification from the unattended sync job** for the serious tier specifically -- the one thing worth interrupting silence for.

**Weekly digest:** `cadence digest` writes a dated summary to a configurable directory and can optionally commit it to a notes/journal repo. Email delivery is intentionally not wired up -- see `digest.py`'s module docstring for the reasoning.

## The mirror: body, code, and AI usage in one place (v0.4.0)

This is the piece that doesn't exist anywhere else. A few WHOOP-MCP servers exist publicly; a health tool alone isn't novel. What's actually new: cadence is the first place your **git commit history**, your **Claude usage**, and your **physiological data** live in one local correlation layer -- all three time-series were already sitting on the same machine, just never joined.

```
cadence config add-repo ~/Downloads/your-project   # point it at your own repos -- fully generic
cadence mirror                                     # commit-hour rhythm + observational correlations
```

**The honesty discipline that makes this real rather than a toy:** every correlation is a simple two-group mean comparison on YOUR OWN history -- no regression, no significance testing dressed up as more than it is -- and it **refuses to report anything with fewer than 14 days of real overlapping data per group**, full stop. Every result that does print carries an explicit methodology note (exactly how the two variables were date-joined, stated plainly as "observational, not causal"). Silence is the honest answer below that threshold, never a confident-sounding number built on noise.

**The honesty guardrail in practice:** the commit-hour histogram is available immediately (a pure histogram has no threshold to clear), but the deeper correlations (late-night coding vs. next-morning recovery, short sleep vs. commit output, heavy Claude usage vs. next-day recovery) return **nothing** until there are 14+ days of real overlapping data per group -- on a fresh setup they correctly report nothing rather than manufacture a "finding" from 2-3 data points, and start reporting real numbers only as the logs accumulate past that threshold.

MCP tools: `get_developer_rhythm()`, `get_body_code_correlations()`.

## On-device narration (v0.5.0)

`cadence digest --narrate` turns the structured weekly digest into 2-3 plain-spoken paragraphs, generated entirely on-device via [langchain-apple-foundation-models](https://github.com/rajanshxrma/langchain-apple-foundation-models). Zero cloud, zero cost, `pip install cadence[narrate]` (optional -- cadence never depends on Apple Intelligence to work).

```
cadence digest --narrate --no-commit   # try it without committing anything
```

**A real hallucination, found and fixed live, not glossed over:** the first working prompt produced a real run where the digest's own baseline section plainly showed a fully-populated recovery baseline (well past the n >= 14 threshold) and the model still wrote "there isn't enough data to compare your recovery score to your 30-day baseline" -- flatly contradicting its own input. Same bug class already documented in [lantern](https://github.com/rajanshxrma/lantern)'s README. Fixed by making the instructions explicit about what "not enough data" is allowed to mean (only when that literal phrase appears in the input); verified across 3 consecutive real generations post-fix with zero hallucinated claims and every number reproduced verbatim. Not proof it can never recur -- prompting alone never fully eliminates hallucination -- which is exactly why the narration is inserted ABOVE the deterministic structured digest, never replacing it: the real numbers are always sitting right there to cross-check against.

## How it works

| | WHOOP | Apple Health |
|---|---|---|
| Where the data comes from | Cloud API, OAuth 2.0 | Local `export.zip` (manual export from the Health app) |
| What it uniquely has | Recovery score, HRV (RMSSD), strain, sleep performance/efficiency | Steps, active/basal energy, exercise minutes, distance, HRV (SDNN), workout GPS-adjacent detail |
| Network calls | Yes, to pull data (`cadence sync`) | None -- pure local file parse |
| Sync model | Pull-based, run `cadence sync` whenever | One-time import per export (re-import to refresh) |

Both sources get normalized into one local SQLite schema (`sleep`, `recovery`, `workouts`, `daily_metrics`), each row tagged with its `source`. When both sources cover the same real-world event (e.g. the same night's sleep), cadence doesn't silently merge them into a fake "canonical" record -- it picks explicitly and says so: WHOOP wins for sleep staging and is the *only* source for recovery/strain (Apple Health has no equivalent computed score); Apple Health is the source for step count and other daily activity WHOOP's API doesn't expose.

## Real engineering challenges (found and handled, not glossed over)

1. **The Apple Health export can be 200MB-1GB+ of XML.** `apple_health.py` streams it with `xml.etree.ElementTree.iterparse` and actively evicts every processed element from the parse tree -- `elem.clear()` alone isn't enough (a well-known stdlib gotcha: the element still sits in its parent's children list). Verified, not just claimed: a 300k-record/48MB synthetic file grew peak RSS by ~19MB with full eviction vs ~27MB with `elem.clear()` alone -- a real, measurable difference that gets far more pronounced at real-export scale (millions of records).
2. **Apple Health emits sleep as dozens of small per-stage records, not one row per night.** `_group_sleep_sessions()` reduces them into real sessions by contiguous time (a >2h gap starts a new night), summing per-stage seconds -- tested against both a gap-split case and a same-night merge case.
3. **WHOOP rotates refresh tokens on every use** -- the old one is invalidated the instant a new one is issued. `auth.py`'s refresh path persists the new refresh token immediately, before returning; dropping that step "to simplify" would break sync after the very next token rotation.
4. **A real off-by-one bug in the trend calculation**, caught by its own test: comparing "this week" vs "last week" with naive inclusive date bounds put the shared boundary day in *both* windows, inflating the recent average (measured: 8625 instead of the correct 9000 in a 9000-vs-6000 synthetic scenario). Fixed by making the two windows properly disjoint.
5. **A real test-isolation bug, caught by its own test:** `store.connect()`'s default argument originally bound `DEFAULT_DB_PATH` at function-*definition* time, so reassigning `store.DEFAULT_DB_PATH` in a test (to isolate it from the real `~/.cadence/cadence.db`) silently had no effect -- an early test run actually wrote real synthetic test rows into the real local store on the dev machine before this was caught and fixed. `tests/test_tools.py::test_tools_never_touch_real_default_db_path` now guards against a regression.
6. **A partial-day bias in the trend math, found by the first run against real data -- the kind of bug synthetic tests can't catch.** The week-over-week trend originally included *today* in the recent window; a `cadence brief` run in the early morning counted that morning's few hundred steps as a full day, and the first live report claimed steps were "down 56% vs the week before" -- partly an artifact of when the command happened to run, not real behavior. A health brief that's subtly wrong depending on the time of day is worse than no brief. Both comparison windows now cover complete days only, ending at yesterday, with a regression test pinning today's exclusion.
7. **WHOOP's `start`/`end` params silently 404 on a bare date.** Found live: `?start=2026-06-10` returns a plain 404 (not a validation error), while the identical request with `?start=2026-06-10T00:00:00.000Z` returns 200 with data. Full ISO8601 datetimes only -- now enforced by `_iso_datetime()` with its own regression test.
8. **The same lexicographic-date-comparison bug, twice, in two different modules.** `sleep`/`workouts` rows store full ISO timestamps ("2026-07-10T03:00:00Z"), which sort *after* a bare date string ("2026-07-10") -- so a bare `date.today().isoformat()` upper bound silently excludes today's own rows. This was already found and fixed once in `apple_health.py`'s tests; it recurred in `digest.py` (a genuinely new bug, not a regression of the first) because the fix pattern (`brief.py`'s `+1 day` buffer) wasn't applied to the new module by default. Now consistent everywhere query bounds are built. Worth remembering: a fixed bug in one module doesn't protect a sibling module written later -- the pattern has to be applied deliberately each time, not assumed.
9. **A real correlation bug, caught by its own test before it ever touched real data.** `late_night_cost()` originally iterated over commit-days to build its two comparison groups -- which meant days with ZERO commits (exactly the "no late-night commit" group it needed) were silently excluded entirely, since they never appear in commit data at all. Fixed by iterating over health-outcome days instead and defaulting missing commit data to zero, the same pattern `sleep_to_output()` already used correctly. Caught by a real end-to-end test (a synthetic repo + synthetic recovery data) before this ever ran against a real account -- had it shipped as written, real correlation results would have been silently computed from a biased, commits-only subset instead of the true comparison.

## Limitations, stated honestly

- **WHOOP field mapping is live-verified against a real account** (a real 30-day sync against a live account pulled a full set of recovery, sleep, and workout records, and the brief rendered them correctly). Two real integration bugs surfaced during that first live run and are fixed with regression tests -- see challenges #6 and #7 above.
- **Apple Health parsing covers a curated allowlist of metric types** (steps, energy, distance, exercise minutes, resting heart rate, HRV-SDNN, respiratory rate, VO2max) and the classic attribute-based `<Workout>` schema -- not every HealthKit type Apple can export, and units are assumed to be Apple's standard emitted units except where explicitly converted.
- **Apple Health workouts have no stable id in the export format** -- cadence synthesizes one from (start time, activity type), an extremely-low-collision approximation, not a real Apple-issued identifier.
- Apple Health's own HRV measure (SDNN) and WHOOP's (RMSSD) are genuinely different statistics -- cadence keeps them in separate fields rather than pretending they're interchangeable.

## Development

```
pip install -e ".[dev]"
pytest -v
```

Real, no-mock tests throughout: a synthetic (non-personal) Apple Health export fixture exercised end-to-end, WHOOP normalization against synthetic-but-realistically-shaped JSON (no live token needed), and every MCP tool invoked through the actual `mcp` protocol layer, not just imported and called directly.

MIT.

