A personal AI cycling coach that reads WHOOP recovery and intervals.icu fitness data each morning and writes a workout straight to your head unit.
Every morning between 5 and 8 AM, a poller checks WHOOP for the day's recovery score. When new data appears, Claude Code reads recovery, sleep, training load (CTL/ATL/TSB), recent activities, the current training phase, and the athlete's choice history, then designs two structured workouts and uploads them to intervals.icu. From there, the workouts auto-sync to a Hammerhead Karoo (or any intervals.icu-connected head unit) ready to be selected and ridden in ERG mode.
There's also a separate FastAPI chat backend that talks to the Anthropic API directly. That's the "ask your coach a question" interface; the daily workflow above uses the Claude Code CLI against your subscription.
cron (every 5 min, 5-8 AM)
└── poll_recovery.py
└── WHOOP v2 API
└── (new recovery score for today?)
└── claude CLI + prompts/daily_coach.md
├── fetch WHOOP + intervals.icu context
├── design two distinct workouts
├── upload via intervals.icu API
└── post writeup to a personal Gist
intervals.icu calendar
└── Hammerhead Karoo (auto-sync, ERG-mode workout ready to start)
Separately:
FastAPI on a private host (api.service)
└── /coach/chat (streaming)
└── Anthropic SDK
└── chat_coach.md template + live training context
This started life as a Cloudflare Worker + ntfy.sh webhook chain. That worked until ntfy.sh began rate-limiting shared Cloudflare Worker egress IPs, at which point the polling approach turned out to be simpler and more reliable. The original worker source lives in docs/legacy/webhook/ for reference.
The interesting bits, if you're poking through for portfolio reasons:
- OAuth + token rotation against WHOOP's v2 API, with refresh handled in-script and tokens persisted to a gitignored file
- An external-API polling pipeline that's idempotent on recovery-score-stable days (compares against last-seen state, only triggers downstream work when something actually changed)
- An LLM as a domain expert with a multi-page system prompt that encodes coaching philosophy, phase-aware decision rules, and a strict output format the rest of the pipeline can parse
- A structured-output format for workouts (
Xm XX%andXm ramp XX-YY%) that the intervals.icu API consumes and the Karoo executes in ERG mode - A feedback loop where the coach reads its own decision history each morning and reasons over the athlete's choice patterns ("they keep picking the shorter option on Mondays") rather than treating each day in isolation
- A workaround for a real upstream API regression: intervals.icu's POST endpoint silently drops
workout_doc.steps, soupload_workout.pydoes a POST + PUT + verify cycle (seeupload_workout.pyfor the gory details)
- Python 3.11+
- An intervals.icu account
- A WHOOP account and a WHOOP developer app (free; for OAuth)
- A Claude Code subscription (for the daily coach workflow)
- An Anthropic API key (only if you want the chat API; not needed for the daily coach)
- Optional: a Hammerhead Karoo or any head unit that syncs with intervals.icu
git clone https://github.com/fpound/workout-coach.git
cd workout-coach
# Daily coach scripts only need requests + stdlib
pip install requests
# If you also want to run the chat API
pip install -r requirements-api.txtSet up the athlete profile first; everything else depends on it.
cp athlete_config.example.json athlete_config.json
$EDITOR athlete_config.jsonFill in your FTP, LTHR, Max HR, intervals.icu athlete ID (the i123456 string in your intervals.icu profile URL), trainer, and head unit. The coach reads this at runtime. The example file is tracked, your real one is not.
cp goals/current_goals.example.md goals/current_goals.md
cp goals/phase_config.example.json goals/phase_config.json
$EDITOR goals/current_goals.md
$EDITOR goals/phase_config.jsoncurrent_goals.md is free-form prose the coach reads to understand what you're training for. phase_config.json tracks which periodization phase you're in (base_1, base_2, build, peak) and your start date.
Create Intervals_API.txt at the repo root with this format:
API Key
<your-api-key-here>
Your API key is at intervals.icu → Settings → Developer.
-
Create a developer app at developer.whoop.com. Set the redirect URI to
http://localhost:8080/callback. -
Create
whoop_config.jsonat the repo root:{ "client_id": "<your-client-id>", "client_secret": "<your-client-secret>", "redirect_uri": "http://localhost:8080/callback" } -
Run the OAuth flow:
python3 scripts/whoop_auth.py
It opens a browser, you approve, tokens land in
whoop_token.json. The script auto-refreshes from then on.
If you want the interactive chat coach in addition to the morning workflow, set these environment variables:
export ANTHROPIC_API_KEY=sk-ant-...
export WORKOUT_COACH_API_KEY=<your-bearer-key-for-the-chat-endpoint>Then start the server:
uvicorn api.main:app --host 0.0.0.0 --port 8082bash run_coach.shThis runs claude against prompts/daily_coach.md, which fetches your data, designs two workouts, and uploads them. Useful for testing and for days when the cron poller didn't fire (e.g. you slept past the polling window).
Add a cron entry to a machine that's always on:
*/5 5-8 * * * /path/to/workout-coach/scripts/poll_recovery.shThis polls WHOOP every 5 minutes during hours 5 through 8 (last poll at 8:55 AM). When a new recovery score appears for today, the poller invokes the coach. State is persisted in data/last_polled_recovery.json so polls without new data are no-ops.
curl -X POST http://localhost:8082/coach/chat \
-H "X-API-Key: $WORKOUT_COACH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"message": "should I do intervals today?"}'The chat endpoint streams responses and remembers conversation history per session.
The coaching philosophy lives in two files:
prompts/daily_coach.mdis the morning system prompt. It's deliberately opinionated: phase-aware (Base 1 vs Base 2 vs Build vs Peak gates which intensities are allowed), recovery-aware but not slave to it ("HIGH GREEN" is a stronger signal than the bare percentage), always offers two truly distinct options (never43min Zone 2vs44min Zone 2), and emits the ERG-compatible step format.prompts/chat_coach.mdis the chat coach template. It uses live data injected from the FastAPI backend ({recovery_score},{ctl},{atl},{tsb}, etc.) so every response is grounded in your actual state.
The reference docs under reference/ (Peter Attia, polarized training, WHOOP recovery interpretation, periodization phase definitions) are also read by the coach during context gathering. They're worth a skim if you want to see how a multi-document RAG-style prompt holds together.
.
├── README.md, LICENSE, CLAUDE.md
├── athlete_config.example.json Template for personal config (tracked)
├── athlete_config.json Real config (gitignored)
├── Intervals_API.txt intervals.icu key (gitignored)
├── whoop_config.json WHOOP client config (gitignored)
├── whoop_token.json WHOOP OAuth tokens, auto-refreshed (gitignored)
├── run_coach.sh Invokes claude CLI against daily_coach.md
├── api.service.example Example systemd unit for the chat API
├── api/ FastAPI chat coach backend
│ ├── main.py, config.py, dependencies.py
│ ├── routers/ HTTP endpoints (chat, recovery, activities, ...)
│ ├── services/ Anthropic, WHOOP, intervals.icu clients
│ └── models/ Pydantic schemas
├── scripts/ CLI scripts the daily coach invokes
│ ├── poll_recovery.py WHOOP recovery poller (cron entry point)
│ ├── poll_recovery.sh Bash wrapper for cron
│ ├── fetch_whoop.py WHOOP data fetcher (recovery/sleep/workouts)
│ ├── fetch_activities.py intervals.icu activity fetcher
│ ├── fetch_wellness.py intervals.icu CTL/ATL/TSB
│ ├── upload_workout.py Upload structured workouts (with POST+PUT workaround)
│ ├── daily_maintenance.py Tracking + cleanup, runs at start of each coach session
│ ├── track_workout_choice.py Compares completed activity to offered options
│ ├── update_coach_history.py Persists choice to coach_history.json
│ ├── show_recent_history.py Surfaces choice patterns to the coach
│ ├── show_phase_status.py Current phase + transition signals
│ ├── cleanup_old_workouts.py Deletes yesterday's un-done planned workouts
│ ├── whoop_auth.py One-shot OAuth flow
│ ├── update_gist.py Posts daily writeup to a personal Gist
│ └── config.py Loads credentials + athlete profile
├── prompts/
│ ├── daily_coach.md Daily workflow system prompt
│ └── chat_coach.md Chat coach template
├── goals/
│ ├── current_goals.example.md Template (tracked)
│ ├── current_goals.md Your goals (gitignored)
│ ├── phase_config.example.json Template (tracked)
│ └── phase_config.json Your phase state (gitignored)
├── reference/ Coaching reference docs the coach reads
│ ├── adaptive_logic.md, peter_attia.md, polarized_training.md
│ ├── power_zones.md, whoop_recovery.md, workout_variety.md
│ ├── strength_integration.md
│ └── periodization/ base_phase, build_phase, overview
├── data/ Cached fetches + history (gitignored)
└── docs/legacy/ Archived components (the old webhook chain)
For unattended operation, run the poller on a Linux box that's always on. The pattern:
pip installthe deps into a virtualenv at<repo>/venvscripts/poll_recovery.shis a thin wrapper that activates the venv before running the Python poller- Cron entry:
*/5 5-8 * * * /home/<user>/workout-coach/scripts/poll_recovery.sh claudeCLI must be installed and logged in for the user that owns the cron job
For the chat API, api.service.example is included as a systemd unit template. Edit the user, paths, and EnvironmentFile location before installing. Do not put ANTHROPIC_API_KEY inline as an Environment= line. Use EnvironmentFile=/etc/workout-coach.env (root-owned, mode 0600) instead, so the key isn't world-readable in /etc/systemd/system/.
- This is personal infrastructure. I run it for myself. There is no support, no SLA, no roadmap.
- Your training is your responsibility. The coach is opinionated software, not a licensed coach. If something it tells you contradicts a real coach, listen to the real coach.
- The intervals.icu POST regression mentioned above (silently dropping
workout_doc.steps) was live as of April 2026. If you see warnings in the upload logs saying the POST+PUT workaround fired, the bug is still upstream. When those warnings stop, the regression is fixed. - WHOOP's API rate-limits aggressively. The poller is designed to be polite (one call per cron run, only when needed). If you change polling frequency, watch for 429s.
MIT. See LICENSE.