Skip to content

fpound/workout-coach

Repository files navigation

Workout Coach

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.

What it does

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.

Architecture

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.

Why it's interesting

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% and Xm 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, so upload_workout.py does a POST + PUT + verify cycle (see upload_workout.py for the gory details)

Setup

Prerequisites

  • 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

Clone and install

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

Athlete profile

Set up the athlete profile first; everything else depends on it.

cp athlete_config.example.json athlete_config.json
$EDITOR athlete_config.json

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

Goals and training phase

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

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

intervals.icu API key

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.

WHOOP OAuth

  1. Create a developer app at developer.whoop.com. Set the redirect URI to http://localhost:8080/callback.

  2. Create whoop_config.json at the repo root:

    {
      "client_id": "<your-client-id>",
      "client_secret": "<your-client-secret>",
      "redirect_uri": "http://localhost:8080/callback"
    }
  3. 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.

Optional: chat API

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 8082

Usage

One-shot coach run

bash run_coach.sh

This 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).

Continuous (recommended)

Add a cron entry to a machine that's always on:

*/5 5-8 * * * /path/to/workout-coach/scripts/poll_recovery.sh

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

Chat API

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 prompts

The coaching philosophy lives in two files:

  • prompts/daily_coach.md is 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 (never 43min Zone 2 vs 44min Zone 2), and emits the ERG-compatible step format.
  • prompts/chat_coach.md is 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.

Project layout

.
├── 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)

Deployment notes

For unattended operation, run the poller on a Linux box that's always on. The pattern:

  • pip install the deps into a virtualenv at <repo>/venv
  • scripts/poll_recovery.sh is 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
  • claude CLI 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/.

Caveats

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

License

MIT. See LICENSE.

About

Adaptive training prescription tool. Uses WHOOP recovery data and intervals.icu TSS to generate next-day cycling workouts via LLM.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors