Are you the author? Sign in to claim
A local-first health-context engine -- pulls WHOOP and Apple Health into one local store, exposes it to Claude via MCP a
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.

Unlike the rest of this portfolio (lantern, stacks, 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.
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).
https://github.com/<your-fork>/cadence#the-honest-privacy-story (this section) is a reasonable choice for a personal-use app.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).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.cadence sync), no webhook receiver.export CADENCE_WHOOP_CLIENT_ID="..."
export CADENCE_WHOOP_CLIENT_SECRET="..."
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.
The piece that makes this more than a data viewer: cadence hook emits a Claude Code SessionStart hook 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.
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:
brief/the session hook -- inline, every time they exist.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.
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().
cadence digest --narrate turns the structured weekly digest into 2-3 plain-spoken paragraphs, generated entirely on-device via 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'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.
| 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.
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)._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.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.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.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.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.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.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.<Workout> schema -- not every HealthKit type Apple can export, and units are assumed to be Apple's standard emitted units except where explicitly converted.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.
Run Claude Code as an MCP server so any agent can delegate coding tasks to it
Browser automation using accessibility snapshots instead of screenshots
Google's universal MCP server supporting PostgreSQL, MySQL, MongoDB, Redis, and 10+ databases
Official GitHub integration for repos, issues, PRs, and CI/CD workflows