{"skill":{"slug":"wahoo-skill","displayName":"Wahoo Skill","summary":"Wahoo Fitness Cloud API — fetch workouts, download FIT files, parse power/HR/cadence/GPS into local SQLite for analysis","description":"---\nname: wahoo-cloud\ndescription: Wahoo Fitness Cloud API — fetch workouts, download FIT files, parse power/HR/cadence/GPS into local SQLite for analysis\nhomepage: https://cloud-api.wahooligan.com/\nmetadata: {\"clawdbot\":{\"emoji\":\"🚴\",\"requires\":{\"bins\":[\"python3\"],\"env\":[\"WAHOO_CLIENT_ID\",\"WAHOO_CLIENT_SECRET\"]},\"primaryEnv\":\"WAHOO_CLIENT_ID\"}}\n---\n\n# Wahoo Cloud API Skill\n\nProgrammatic access to the Wahoo Fitness Cloud API for ELEMNT BOLT/ROAM/ACE head units. Fetches workout metadata, downloads FIT files from Wahoo's CDN, and parses ride data (power, cadence, HR, GPS, elevation) into a local SQLite database.\n\nAPI base: `https://api.wahooligan.com`. Workout endpoints live under `/v1/workouts`. OAuth2 with the `offline_data` scope yields a long-lived refresh token; access tokens expire after ~2 hours and the skill auto-refreshes on 401.\n\n## Agent quickstart (read this first)\n\nIf you're an agent invoking this skill on behalf of a user:\n\n| User asks | Run this |\n|---|---|\n| \"Sync my Wahoo workouts\" / \"Pull new rides\" | `python3 {baseDir}/scripts/fetch_workouts.py` |\n| \"Show recent rides\" / \"Last week's training\" | Query `~/.openclaw/workspace/training/wahoo.db` (or `$WAHOO_TRAINING_DIR/wahoo.db`) — schema below |\n| \"Show lap splits for a ride\" | `SELECT * FROM laps WHERE workout_id = <id> ORDER BY lap_number` |\n| \"Show power / HR time series for a ride\" | `SELECT timestamp, power_w, heart_rate, cadence, speed_ms FROM records WHERE workout_id = <id> ORDER BY timestamp` |\n| \"Show GPS track for a ride\" | `SELECT timestamp, position_lat_deg, position_long_deg, enhanced_altitude_m FROM records WHERE workout_id = <id> AND position_lat_deg IS NOT NULL ORDER BY timestamp` |\n| \"Show sensors / devices on a ride\" | `SELECT manufacturer, product_name, serial_number, battery_status FROM device_info WHERE workout_id = <id>` |\n| \"Show power or HR zone thresholds\" | `SELECT zone_type, zone_number, high_value FROM zones WHERE workout_id = <id> ORDER BY zone_type, zone_number` |\n| \"Show FTP / threshold power\" | `SELECT id, starts, fit_threshold_power_w FROM workouts ORDER BY starts DESC` |\n| \"Parse this FIT file\" | `python3 {baseDir}/scripts/parse_fit.py PATH.fit [--summary-only]` |\n| \"Set up Wahoo\" / \"Connect my Wahoo\" | Walk the user through Setup §1–3 below; then run `python3 {baseDir}/scripts/oauth_setup.py` |\n| \"Refresh my Wahoo token\" | `bash {baseDir}/scripts/refresh_token.sh` (only needed if auto-refresh fails) |\n\nThe fetch script is **idempotent** — safe to run on a heartbeat. It skips workouts already fully synced (`fit_parsed_at IS NOT NULL`). Sandbox rate limits (25 req / 5 min) trigger automatic backoff, so a first sync of a long history may take many minutes.\n\nThe skill **cannot** ship credentials. Each user needs their own Wahoo developer app — no shortcut. Setup is a one-time browser handshake.\n\n**Credential auto-loading:** if `WAHOO_CLIENT_ID` / `WAHOO_CLIENT_SECRET` aren't in the calling shell, `wahoo_auth.py` automatically reads them from `~/.openclaw/secrets/wahoo.env` (override path with `$WAHOO_ENV_FILE`). This means an OpenClaw agent can invoke `fetch_workouts.py` without sourcing anything — token refresh just works.\n\n## Setup\n\n### 1. Register a Wahoo Developer App\n\n1. Go to https://developers.wahooligan.com\n2. Create an application (Sandbox is automatic — no review)\n3. Set callback URL (e.g. `https://localhost:8080/` — the manual-paste OAuth helper works with any registered callback)\n4. Request scopes: `workouts_read offline_data user_read` (add `power_zones_read plans_read routes_read` if you want zones/plans/routes)\n5. Note your **Client ID** and **Client Secret**\n\n### 2. Configure Credentials\n\nAdd to `~/.clawdbot/clawdbot.json`:\n```json\n{\n  \"skills\": {\n    \"entries\": {\n      \"wahoo\": {\n        \"enabled\": true,\n        \"env\": {\n          \"WAHOO_CLIENT_ID\": \"your-client-id\",\n          \"WAHOO_CLIENT_SECRET\": \"your-client-secret\",\n          \"WAHOO_REDIRECT_URI\": \"https://localhost:8080/\"\n        }\n      }\n    }\n  }\n}\n```\n\nOr as environment variables:\n```bash\nexport WAHOO_CLIENT_ID=\"...\"\nexport WAHOO_CLIENT_SECRET=\"...\"\nexport WAHOO_REDIRECT_URI=\"https://localhost:8080/\"\n```\n\n### 3. Run OAuth2 Flow\n\n```bash\npython3 {baseDir}/scripts/oauth_setup.py\n```\n\nThe script prints an authorization URL. Open it in a browser, log in with your Wahoo account, approve. You'll be redirected to your callback URL with `?code=...` in the query string (the page itself will fail to load — that's expected; just copy the URL or the `code` value). Paste it back into the script. It exchanges the code for tokens and writes them to `~/.openclaw/secrets/wahoo_tokens.json`.\n\n### 4. Fetch Workouts\n\n```bash\npython3 ~/.openclaw/workspace/training/fetch_wahoo.py\n```\n\nThis pulls the workout list, fetches detail (and FIT URL) for each new workout, downloads FIT files into `~/.openclaw/workspace/training/wahoo_fit/`, parses them, and upserts records into `~/.openclaw/workspace/training/wahoo.db`.\n\n## Usage\n\n### List Workouts (paginated)\n\n```bash\ncurl -s -H \"Authorization: Bearer ${WAHOO_ACCESS_TOKEN}\" \\\n  \"https://api.wahooligan.com/v1/workouts?page=1&per_page=30\"\n```\n\nResponse shape: `{ workouts: [...], total, page, per_page, order, sort }`. Note that `workout_summary` is `null` in the list response — fetch detail per workout to get summary + FIT URL.\n\n### Get Workout Detail (with FIT URL)\n\n```bash\ncurl -s -H \"Authorization: Bearer ${WAHOO_ACCESS_TOKEN}\" \\\n  \"https://api.wahooligan.com/v1/workouts/WORKOUT_ID\"\n```\n\nThe FIT file URL lives at `workout_summary.file.url`.\n\n### Download a FIT File\n\n```bash\ncurl -L -o ride.fit \"$FIT_URL\"\n```\n\nThe CDN doesn't require auth and doesn't count against your API rate limit.\n\n### Get User Profile\n\n```bash\ncurl -s -H \"Authorization: Bearer ${WAHOO_ACCESS_TOKEN}\" \\\n  \"https://api.wahooligan.com/v1/user\"\n```\n\nReturns `{ id, height, weight, first, last, email, birth, gender, created_at, updated_at }`. Height and weight are returned as decimal strings.\n\n### Refresh Access Token\n\n```bash\nbash {baseDir}/scripts/refresh_token.sh\n```\n\nThe Python OAuth helper auto-refreshes on 401 if a `refresh_token` is on file. The shell helper is for manual/cron use.\n\n```bash\ncurl -s -X POST https://api.wahooligan.com/oauth/token \\\n  -d client_id=\"${WAHOO_CLIENT_ID}\" \\\n  -d client_secret=\"${WAHOO_CLIENT_SECRET}\" \\\n  -d grant_type=refresh_token \\\n  -d refresh_token=\"${WAHOO_REFRESH_TOKEN}\"\n```\n\nThe new access token does not invalidate the old one until you successfully use it (Wahoo allows up to 10 unrevoked access tokens per user as of Jan 2026).\n\n## Common Data Fields\n\n`workout_summary` includes:\n- `ascent_accum` — total elevation gain (m)\n- `cadence_avg` — average cadence (rpm)\n- `calories_accum` — kcal\n- `distance_accum` — distance (m)\n- `duration_active_accum` / `duration_paused_accum` / `duration_total_accum` — seconds\n- `heart_rate_avg` — bpm\n- `power_avg` — average power (W)\n- `power_bike_np_last` — normalized power\n- `power_bike_tss_last` — Training Stress Score\n- `speed_avg` — m/s\n- `work_accum` — total work (J)\n- `time_zone` — IANA tz\n- `file.url` — FIT file URL (CDN)\n\nAll decimal values are returned as strings — cast before math.\n\n`workout_type_id` is an integer enum whose mapping isn't published. Empirically observed: cycling rides come back as `0`. Treat as opaque and key off the FIT `sport`/`sub_sport` fields (parsed into `workouts.fit_*` columns) when you need to filter by activity type.\n\n## Rate Limits\n\n| Tier | per 5 min | per hour | per day |\n|------|-----------|----------|---------|\n| Sandbox | 25 | 100 | 250 |\n| Production | 200 | 1,000 | 5,000 |\n\nHeaders: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`. The sync pipeline backs off automatically on 429.\n\n## Tips\n\n- Convert m → mi: divide by 1609.34\n- Convert m/s → mph: multiply by 2.237\n- Decimal strings: `float(workout_summary[\"power_avg\"])` before any math\n- The list endpoint omits `workout_summary` — always hit detail (`/v1/workouts/:id`) to get FIT URL\n\n## Error Handling\n\n| Status | Meaning | Action |\n|--------|---------|--------|\n| 401 | Access token expired/invalid | Run `refresh_token.sh` or let `wahoo_auth.py` auto-refresh |\n| 403 | Scope insufficient | Re-authorize with the missing scope |\n| 429 | Rate limit hit | Wait until `X-RateLimit-Reset` |\n| 404 | Workout not found / not yours | Confirm ID + ownership |\n","tags":{"latest":"0.1.9"},"stats":{"comments":0,"downloads":503,"installsAllTime":19,"installsCurrent":0,"stars":0,"versions":6},"createdAt":1777905618951,"updatedAt":1778686686828},"latestVersion":{"version":"0.1.9","createdAt":1778686481244,"changelog":"Limit list API calls to per_page=1 to avoid rate limits","license":"MIT-0"},"metadata":{"setup":[{"key":"WAHOO_CLIENT_ID","required":true},{"key":"WAHOO_CLIENT_SECRET","required":true}],"os":null,"systems":null},"owner":{"handle":"tgmerritt","userId":"s1755sfb9q331dk6dwfxy0qsen86093e","displayName":"Tyler","image":"https://avatars.githubusercontent.com/u/1245864?v=4"},"moderation":null}