{"id":"13cb502e-04c0-479c-a040-abed676d474f","entityType":"agent","slug":"clawhub-tgmerritt-wahoo-cloud","name":"Wahoo Fitness Cloud API","canonicalUrl":"https://www.xpersona.co/agent/clawhub-tgmerritt-wahoo-cloud","canonicalPath":"/agent/clawhub-tgmerritt-wahoo-cloud","generatedAt":"2026-10-11T05:32:09.150Z","source":"CLAWHUB","claimStatus":"UNCLAIMED","verificationTier":"NONE","summary":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-11T02:53:13.526Z","emptyReason":null},"description":"Access Wahoo Fitness Cloud API to fetch workouts, download FIT files, and analyze training data (power, HR, cadence, GPS).","descriptionLabel":"Source description","evidenceSummary":"Capability contract not published. No trust telemetry is available yet. 1.2K downloads reported by the source. Last updated 10/11/2026.","installCommand":"clawhub skill install s1755sfb9q331dk6dwfxy0qsen86093e:wahoo-cloud","sourceUrl":"https://clawhub.ai/tgmerritt/wahoo-cloud","homepage":"https://clawhub.ai/tgmerritt/skills/wahoo-cloud","primaryLinks":[{"label":"View on ClawHub","url":"https://clawhub.ai/tgmerritt/wahoo-cloud","kind":"source"},{"label":"Homepage","url":"https://clawhub.ai/tgmerritt/skills/wahoo-cloud","kind":"homepage"}],"safetyScore":84,"overallRank":62,"popularityScore":61,"trustScore":null,"claimedByName":null,"isOwner":false,"seoDescription":"Wahoo Fitness Cloud API technical dossier on Xpersona with agent coverage, OPENCLEW support, and live trust metadata."},"coverage":{"evidence":{"source":"public-profile","verified":false,"confidence":"medium","updatedAt":"2026-10-11T02:53:13.526Z","emptyReason":null},"protocols":[{"protocol":"OPENCLEW","label":"OpenClaw","status":"self-declared","notes":"Declared in the public agent profile."}],"capabilities":[],"verifiedCount":0,"selfDeclaredCount":1,"capabilityMatrix":{"rows":[{"key":"OPENCLEW","type":"protocol","support":"unknown","confidenceSource":"profile","notes":"Listed on profile"}],"flattenedTokens":"protocol:OPENCLEW|unknown|profile"}},"adoption":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-11T02:53:13.526Z","emptyReason":null},"stars":null,"forks":null,"downloads":1182,"packageName":null,"latestVersion":"0.2.1","tractionLabel":"1.2K downloads"},"release":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-11T02:53:13.369Z","emptyReason":null},"lastUpdatedAt":"2026-10-11T02:53:13.526Z","lastCrawledAt":"2026-10-11T02:53:13.369Z","lastIndexedAt":null,"nextCrawlAt":"2026-10-12T02:53:13.369Z","lastVerifiedAt":null,"highlights":[{"version":"0.2.1","createdAt":"2026-05-26T20:45:31.856Z","changelog":"Make skill runtime-agnostic: align WAHOO_BASE_DIR defaults, remove OpenClaw-specific refs from published libs, add --limit flag, restore error reference table, fix SKILL.md doc errors, add requirements.txt","fileCount":14,"zipByteSize":22692},{"version":"0.2.0","createdAt":"2026-05-13T19:23:20.800Z","changelog":"Throttle to 13s between requests (Wahoo sandbox: 25/5-min); revert per_page=30 to minimize total call count","fileCount":13,"zipByteSize":25918},{"version":"0.1.9","createdAt":"2026-05-13T15:34:59.222Z","changelog":"Limit list API calls to per_page=1 to avoid rate limits","fileCount":13,"zipByteSize":25738},{"version":"0.1.8","createdAt":"2026-05-06T20:13:27.374Z","changelog":"v0.1.8: full FIT capture — records, device_info, zones, session/lap gaps","fileCount":13,"zipByteSize":25741},{"version":"0.1.4","createdAt":"2026-05-06T02:24:04.450Z","changelog":"Add lap splits extraction from FIT files into local SQLite DB","fileCount":14,"zipByteSize":27004},{"version":"0.1.3","createdAt":"2026-05-04T00:20:16.768Z","changelog":"Security fixes: redact tokens in wahoo_auth.py __main__; pin fitparse dependency; exclude internal planning docs from bundle; expand README security guidance.","fileCount":13,"zipByteSize":21558},{"version":"0.1.2","createdAt":"2026-05-04T00:14:52.588Z","changelog":"wahoo_auth.py auto-loads WAHOO_* vars from ~/.openclaw/secrets/wahoo.env when not already in env. Lets agents trigger sync without sourcing the env file. Explicit env still wins. $WAHOO_ENV_FILE overrides path.","fileCount":15,"zipByteSize":27462},{"version":"0.1.1","createdAt":"2026-05-04T00:04:38.110Z","changelog":"Include MIT LICENSE.md in the published bundle.","fileCount":15,"zipByteSize":26853}]},"execution":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No published capability contract is available yet."},"installCommand":"clawhub skill install s1755sfb9q331dk6dwfxy0qsen86093e:wahoo-cloud","setupComplexity":"low","setupSteps":["Install using `clawhub skill install s1755sfb9q331dk6dwfxy0qsen86093e:wahoo-cloud` in an isolated environment before connecting it to live workloads.","No published capability contract is available yet, so validate auth and request/response behavior manually.","Review the upstream CLAWHUB listing at https://clawhub.ai/tgmerritt/wahoo-cloud before using production credentials."],"contract":{"contractStatus":"missing","authModes":[],"requires":[],"forbidden":[],"supportsMcp":false,"supportsA2a":false,"supportsStreaming":false,"inputSchemaRef":null,"outputSchemaRef":null,"dataRegion":null,"contractUpdatedAt":null,"sourceUpdatedAt":null,"freshnessSeconds":null},"invocationGuide":{"preferredApi":{"snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-tgmerritt-wahoo-cloud/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-tgmerritt-wahoo-cloud/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-tgmerritt-wahoo-cloud/trust"},"curlExamples":["curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-tgmerritt-wahoo-cloud/snapshot\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-tgmerritt-wahoo-cloud/contract\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-tgmerritt-wahoo-cloud/trust\""],"jsonRequestTemplate":{"query":"summarize this repo","constraints":{"maxLatencyMs":2000,"protocolPreference":["OPENCLEW"]}},"jsonResponseTemplate":{"ok":true,"result":{"summary":"...","confidence":0.9},"meta":{"source":"CLAWHUB","generatedAt":"2026-10-11T05:32:09.146Z"}},"retryPolicy":{"maxAttempts":3,"backoffMs":[500,1500,3500],"retryableConditions":["HTTP_429","HTTP_503","NETWORK_TIMEOUT"]}},"endpoints":{"dossierUrl":"https://www.xpersona.co/api/v1/agents/clawhub-tgmerritt-wahoo-cloud/dossier","snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-tgmerritt-wahoo-cloud/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-tgmerritt-wahoo-cloud/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-tgmerritt-wahoo-cloud/trust"}},"reliability":{"evidence":{"source":"runtime-metrics","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No trust, reliability, or runtime telemetry is available."},"trust":{"status":"unavailable","handshakeStatus":"UNKNOWN","verificationFreshnessHours":null,"reputationScore":null,"p95LatencyMs":null,"successRate30d":null,"fallbackRate":null,"attempts30d":null,"trustUpdatedAt":null,"trustConfidence":"unknown","sourceUpdatedAt":null,"freshnessSeconds":null},"decisionGuardrails":{"doNotUseIf":["Contract metadata is missing or unavailable for deterministic execution."],"safeUseWhen":[],"riskFlags":["missing_or_unavailable_contract","trust_data_unavailable","schema_references_missing"],"operationalConfidence":"low"},"executionMetrics":{"observedLatencyMsP50":null,"observedLatencyMsP95":null,"estimatedCostUsd":null,"uptime30d":null,"rateLimitRpm":null,"rateLimitBurst":null,"lastVerifiedAt":null,"verificationSource":null},"runtimeMetrics":{"successRate":null,"avgLatencyMs":null,"avgCostUsd":null,"hallucinationRate":null,"retryRate":null,"disputeRate":null,"p50Latency":null,"p95Latency":null,"lastUpdated":null}},"benchmarks":{"evidence":{"source":"no-benchmark-data","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No benchmark suites or observed failure patterns are available."},"suites":[],"failurePatterns":[]},"artifacts":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-11T02:53:13.526Z","emptyReason":null},"readme":"Skill: Wahoo Fitness Cloud API\n\nOwner: tgmerritt\n\nSummary: Access Wahoo Fitness Cloud API to fetch workouts, download FIT files, and analyze training data (power, HR, cadence, GPS).\n\nTags: cycling:0.1.3, fit:0.1.3, fitness:0.1.3, latest:0.2.1, oauth2:0.1.3, strava-alternative:0.1.3, wahoo:0.1.3\n\nVersion history:\n\nv0.2.1 | 2026-05-26T20:45:31.856Z | user\n\nMake skill runtime-agnostic: align WAHOO_BASE_DIR defaults, remove OpenClaw-specific refs from published libs, add --limit flag, restore error reference table, fix SKILL.md doc errors, add requirements.txt\n\nv0.2.0 | 2026-05-13T19:23:20.800Z | user\n\nThrottle to 13s between requests (Wahoo sandbox: 25/5-min); revert per_page=30 to minimize total call count\n\nv0.1.9 | 2026-05-13T15:34:59.222Z | user\n\nLimit list API calls to per_page=1 to avoid rate limits\n\nv0.1.8 | 2026-05-06T20:13:27.374Z | user\n\nv0.1.8: full FIT capture — records, device_info, zones, session/lap gaps\n\nv0.1.4 | 2026-05-06T02:24:04.450Z | user\n\nAdd lap splits extraction from FIT files into local SQLite DB\n\nv0.1.3 | 2026-05-04T00:20:16.768Z | user\n\nSecurity fixes: redact tokens in wahoo_auth.py __main__; pin fitparse dependency; exclude internal planning docs from bundle; expand README security guidance.\n\nv0.1.2 | 2026-05-04T00:14:52.588Z | user\n\nwahoo_auth.py auto-loads WAHOO_* vars from ~/.openclaw/secrets/wahoo.env when not already in env. Lets agents trigger sync without sourcing the env file. Explicit env still wins. $WAHOO_ENV_FILE overrides path.\n\nv0.1.1 | 2026-05-04T00:04:38.110Z | user\n\nInclude MIT LICENSE.md in the published bundle.\n\nv0.1.0 | 2026-05-03T23:01:22.126Z | user\n\ntest\n\nArchive index:\n\nArchive v0.2.1: 14 files, 22692 bytes\n\nFiles: config.example.json (385b), lib/fit_parser.py (7002b), lib/wahoo_api.py (4986b), lib/wahoo_auth.py (6638b), LICENSE.md (1070b), README.md (3526b), requirements.txt (11b), schema/wahoo_db_schema.sql (6328b), scripts/fetch_workouts.py (22527b), scripts/oauth_setup.py (2346b), scripts/parse_fit.py (767b), skill-card.md (2335b), SKILL.md (3880b), _meta.json (130b)\n\nFile v0.2.1:SKILL.md\n\n---\nname: wahoo-cloud\ndescription: Access Wahoo Fitness Cloud API to fetch workouts, download FIT files, and analyze training data (power, HR, cadence, GPS).\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 Skill\n\nProvides programmatic access to Wahoo Fitness Cloud data. This skill manages OAuth2 authentication, workout synchronization, and FIT file processing.\n\n## 🚀 Agent Quickstart\n\nWhen a user asks about their Wahoo workouts, training, or fitness data, use these workflows:\n\n| User Intent | Action / Command |\n| :--- | :--- |\n| **\"Sync my Wahoo workouts\"** | `python3 {baseDir}/scripts/fetch_workouts.py` |\n| **\"Show my recent rides\"** | Query the local SQLite DB: `$WAHOO_BASE_DIR/wahoo.db (default: ~/.wahoo/wahoo.db)` — schema below |\n| **\"Parse this FIT file\"** | `python3 {baseDir}/scripts/parse_fit.py <PATH_TO_FIT>` |\n| **\"Connect/Set up Wahoo\"** | 1. Verify `WAHOO_CLIENT_ID` and `WAHOO_CLIENT_SECRET` exist.<br>2. If missing, ask user to provide them.<br>3. Run `python3 {baseDir}/scripts/oauth_setup.py` |\n| **\"Token expired / persistent 401 errors\"** | Run python3 {baseDir}/scripts/oauth_setup.py to re-authorize. Force-refresh without browser: python3 -c \"import sys; sys.path.insert(0,'lib'); import wahoo_auth; wahoo_auth.refresh()\" |\n\n## 🛠 Key Workflows\n\n### 1. Authentication & Setup\nThis skill requires a Wahoo Developer App. \n- **Credentials:** Must be provided as `WAHOO_CLIENT_ID` and `WAHOO_CLIENT_SECRET`.\n- **OAuth Flow:** If tokens are missing or expired, run `scripts/oauth_setup.py`. This is an **interactive** process. You must present the URL to the user, wait for them to authenticate, and then ask them to paste the resulting redirect URL/code back into the terminal.\n- **Token Storage:** Tokens are stored at $WAHOO_BASE_DIR/secrets/wahoo_tokens.json (controlled by WAHOO_BASE_DIR env var, default ~/.wahoo).\n\n### 2. Data Synchronization\n- **Command:** `python3 scripts/fetch_workouts.py`\n- **Behavior:** Idempotent. It fetches new workouts, downloads FIT files, and populates the local SQLite database.\n- **Database Path:** `$WAHOO_BASE_DIR/wahoo.db (default: ~/.wahoo/wahoo.db)`\n- **Rate Limits:** The script automatically handles Wahoo's sandbox rate limits (25 req / 5 min) using exponential backoff.\n\n### 3. Querying Training Data\nOnce synced, use the following SQL patterns to answer user questions:\n\n- **Recent Workouts:** `SELECT * FROM workouts ORDER BY starts DESC LIMIT 10;`\n- **Power/HR Series:** `SELECT timestamp, power_w, heart_rate, cadence FROM records WHERE workout_id = <id> ORDER BY timestamp;`\n- **Elevation/GPS:** `SELECT timestamp, position_lat_deg, position_long_deg, enhanced_altitude_m FROM records WHERE workout_id = <id>;`\n\n## ⚠️ Critical Constraints & Pitfalls\n\n- **Rate Limiting:** Wahoo Sandbox is strict. Do not attempt to loop calls rapidly; rely on the `wahoo_api.py` built-in backoff.\n- **Data Types:** The Wahoo API returns decimal values as **strings**. Always cast to `float()` before performing mathematical analysis.\n- **Metric Conversion:** \n  - Meters to Miles: `m / 1609.34`\n  - Meters/Second to MPH: `m/s * 2.237`\n- **Workout Summaries:** The `/v1/workouts` list endpoint returns `null` for `workout_summary`. You **must** call `/v1/workouts/<id>` to get specific metrics like NP, TSS, or the FIT file URL.\n\n## Error Reference\n\n| HTTP Code | Meaning | Resolution |\n| :--- | :--- | :--- |\n| 401 | Token expired/invalid — auto-refresh runs once | Re-run oauth_setup.py if it keeps failing |\n| 403 | Insufficient scope | Re-authorize with missing scope in WAHOO_SCOPES |\n| 429 | Rate limit | wahoo_api.py backs off automatically, use --limit to reduce call volume |\n| 404 | Workout not found | Confirm ID and ownership |\n\nFile v0.2.1:README.md\n\n# Wahoo Skill\n\nAn agentic skill for synchronizing Wahoo fitness data into a local SQLite database. Designed to be high-signal for LLM agents and comprehensive for human developers.\n\n## 🚀 Agent Quickstart (High-Signal)\n\nUse these commands to interact with Wahoo data.\n\n### Core Capabilities\n- **Sync Data**: `python3 scripts/fetch_workouts.py [--limit N]`\n  - Fetches workout metadata and downloads/parses FIT files.\n  - `--limit N`: Only process the N most recent workouts (useful for testing/rate-limit avoidance).\n- **Auth Setup**: `python3 scripts/oauth_setup.py`\n  - Interactive OAuth2 flow to generate access and refresh tokens.\n\n### Environment Variables\n| Variable | Description | Default |\n| :--- | :--- | :--- |\n| `WAHOO_BASE_DIR` | Base directory for all Wahoo files (secrets, DB, FITs) | `~/.wahoo` |\n| `WAHOO_CLIENT_ID` | Wahoo API Client ID | *Required* |\n| `WAHOO_CLIENT_SECRET` | Wahoo API Client Secret | *Required* |\n\n### Data Schema (SQLite)\nThe data is stored in `[WAHOO_BASE_DIR]/wahoo.db`.\n\n**Key Tables:**\n- `workouts`: Metadata (name, starts, distance, duration, power, heart rate, etc.)\n- `laps`: Detailed lap-by-lap breakdown of each workout.\n- `records`: High-resolution sensor data (power, heart rate, cadence, GPS).\n- `device_info`: Hardware details used during the workout.\n- `zones`: Heart rate and power zone data.\n- `sync_log`: History of synchronization runs.\n\n---\n\n## 🛠 Developer Guide (Human-Centric)\n\n## Overview\nThis skill provides a bridge between the Wahoo API and a local, structured SQLite database. It handles the complexities of OAuth2 token refreshing, paginated API requests, FIT file downloading, and intensive data parsing.\n\n## Architecture\n\n### 1. Authentication Flow\nThe skill uses the OAuth2 Authorization Code flow.\n- **Tokens Location**: `[WAHOO_BASE_DIR]/secrets/wahoo_tokens.json`\n- **Environment File**: `[WAHOO_BASE_DIR]/secrets/wahoo.env` (Optional, used to load credentials).\n\n### 2. Sync Workflow\nThe `fetch_workouts.py` script follows this pipeline:\n1. **Auth**: Ensures a valid access token is available (refreshes if necessary).\n2. **Discovery**: Iterates through `/v1/workouts` to find new or updated metadata.\n3. **Metadata Upsert**: Updates the `workouts` table with the latest summary data.\n4. **FIT Processing**: \n   - If a workout lacks a local FIT file or parsed data:\n   - Downloads the `.fit` file from the `fit_url` provided by Wahoo.\n   - Uses `fit_parser` to extract high-resolution metrics.\n   - Populates `laps`, `records`, `device_info`, and `zones` tables.\n\n### 3. Database Schema\nThe schema is managed via `schema/wahoo_db_schema.sql` and incremental migrations defined in `scripts/fetch_workouts.py`.\n\n## Installation & Setup\n\n1. **Clone the repository.**\n2. **Install dependencies**:\n   ```bash\n   pip install -r requirements.txt\n   ```\n3. **Configure Credentials**:\n   Create a `.env` file in your `WAHOO_BASE_DIR` or set them in your shell:\n   ```bash\n   export WAHOO_CLIENT_ID='your_id'\n   export WAHOO_CLIENT_SECRET='your_secret'\n   ```\n4. **Run OAuth Setup**:\n   ```bash\n   python3 scripts/oauth_setup.py\n   ```\n\n## Troubleshooting\n\n### Rate Limiting (429 Errors)\nThe `wahoo_api` library includes built-in exponential backoff. If you encounter persistent 429s, use the `--limit` flag in the sync script to reduce the payload size per run.\n\n### Database Errors\nIf you encounter \"no such column\" errors, ensure you have run a full sync recently to allow the migration logic in `fetch_workouts.py` to update your schema.\n\n## License\nMIT\n\nFile v0.2.1:_meta.json\n\n{\n  \"ownerId\": \"kn705nhwfhwpf3p7cras3v44wd861fwr\",\n  \"slug\": \"wahoo-cloud\",\n  \"version\": \"0.2.1\",\n  \"publishedAt\": 1779828331856\n}\n\nFile v0.2.1:LICENSE.md\n\nMIT License\n\nCopyright (c) 2026 Tyler Merritt\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\nFile v0.2.1:skill-card.md\n\n## Description:\n\nAccess Wahoo Fitness Cloud API to fetch workouts, download FIT files, and analyze training data (power, HR, cadence, GPS).\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[tgmerritt](https://clawhub.ai/user/tgmerritt)\n\n### License/Terms of Use:\n\nMIT\n\n## Use Case:\n\nDevelopers and agents use this skill to connect to Wahoo Fitness Cloud, synchronize workout data, parse FIT files, and answer training questions from a local SQLite database.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: OAuth tokens and detailed workout or GPS data may be exposed on shared machines, synced folders, or agent workspaces.\n\nMitigation: Use a private WAHOO_BASE_DIR with restrictive permissions and avoid placing the database, FIT files, or token files in shared locations.\n\nRisk: OAuth setup output and logs can reveal sensitive authentication details.\n\nMitigation: Avoid logging OAuth setup sessions or sharing terminal output from authorization and token setup.\n\nRisk: The default OAuth scopes may be broader than a user's immediate task requires.\n\nMitigation: Reduce WAHOO_SCOPES where possible before authorizing the skill.\n\nRisk: Dependency installation may be fragile because the dependency name and version are not pinned in requirements.txt.\n\nMitigation: Fix and pin the FIT parsing dependency before running pip install in a production or shared environment.\n\n## Reference(s):\n\n- [Wahoo Fitness Cloud API](https://cloud-api.wahooligan.com/)\n- [ClawHub Skill Page](https://clawhub.ai/tgmerritt/skills/wahoo-cloud)\n\n## Skill Output:\n\n**Output Type(s):** [text, markdown, code, shell commands, configuration, guidance]\n\n**Output Format:** [Markdown with shell commands, SQL examples, configuration guidance, and local file paths]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [May create or update local OAuth token files, FIT downloads, and a SQLite workout database when the documented commands are run.]\n\n## Skill Version(s):\n\n0.2.1 (source: server-resolved release evidence)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nFile v0.2.1:config.example.json\n\n{\n  \"skills\": {\n    \"entries\": {\n      \"wahoo-cloud\": {\n        \"enabled\": true,\n        \"env\": {\n          \"WAHOO_CLIENT_ID\": \"your-client-id-from-developers.wahooligan.com\",\n          \"WAHOO_CLIENT_SECRET\": \"your-client-secret\",\n          \"WAHOO_REDIRECT_URI\": \"https://localhost:8080/\",\n          \"WAHOO_SCOPES\": \"workouts_read offline_data user_read\"\n        }\n      }\n    }\n  }\n}\n\nFile v0.2.1:requirements.txt\n\nfit-parser\n\nArchive v0.2.0: 13 files, 25918 bytes\n\nFiles: config.example.json (385b), lib/fit_parser.py (7002b), lib/wahoo_api.py (4995b), lib/wahoo_auth.py (6619b), LICENSE.md (1070b), README.md (8259b), schema/wahoo_db_schema.sql (6328b), scripts/fetch_workouts.py (22208b), scripts/oauth_setup.py (2302b), scripts/parse_fit.py (767b), scripts/refresh_token.sh (1823b), SKILL.md (8401b), _meta.json (130b)\n\nFile v0.2.0:SKILL.md\n\n---\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\nFile v0.2.0:README.md\n\n# Wahoo Cloud API Skill for OpenClaw\n\nAn [OpenClaw](https://openclaw.dev) / [ClawHub](https://clawhub.ai) skill that gives any agent — or any Python script — programmatic access to the [Wahoo Fitness Cloud API](https://cloud-api.wahooligan.com/). Pulls workouts off the Wahoo cloud, downloads the raw FIT files from Wahoo's CDN, parses them with [`fitparse`](https://github.com/dtcooper/python-fitparse), and lands the result in a local SQLite database you control.\n\nWahoo ELEMNT BOLT, ROAM, and ACE generate gold-standard cycling telemetry — power, cadence, heart rate, GPS, elevation, all at 1 Hz — but Wahoo has historically lacked the third-party ecosystem Garmin and Strava enjoy. This skill closes that gap.\n\n## What you get\n\n- **OAuth2 setup helper** — manual-paste flow that works with any registered callback URL, no local HTTPS server required\n- **Auto-refresh** of access tokens via the `offline_data` refresh token\n- **API client** — workout list, workout detail, user profile, with built-in 429 backoff\n- **FIT downloader** — pulls files from Wahoo's CDN (no API rate-limit cost)\n- **FIT parser** — power, cadence, HR, GPS, elevation → JSON / SQLite\n- **SQLite sync pipeline** — incremental, idempotent, safe to re-run\n\n## Status\n\n- API client + parser: working, tested against ~650 real workouts\n- OAuth flow: confidential-app sandbox tested\n- Production approval from Wahoo: not yet submitted\n\n## Quick start\n\n### 1. Register a Wahoo developer app\n\n1. Go to [developers.wahooligan.com](https://developers.wahooligan.com).\n2. Create an application. Sandbox is automatic — no review required.\n3. Set the callback URL (e.g. `https://localhost:8080/`). The OAuth helper uses manual paste, so any URL you can reach in a browser works.\n4. Request scopes. Minimum useful set: `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. Install dependencies\n\n```bash\npip install --user 'fitparse>=1.2,<2'\n```\n\n(`fitparse` is the only Python dependency. `curl` is needed for the shell refresh helper.)\n\n### 3. Configure credentials\n\n```bash\nexport WAHOO_CLIENT_ID=\"...\"\nexport WAHOO_CLIENT_SECRET=\"...\"\nexport WAHOO_REDIRECT_URI=\"https://localhost:8080/\"\nexport WAHOO_SCOPES=\"workouts_read offline_data user_read\"\n```\n\nOr, if you're running inside OpenClaw, add the equivalent block to `~/.clawdbot/clawdbot.json` — see [`config.example.json`](config.example.json).\n\n### 4. Run the OAuth handshake\n\n```bash\npython3 scripts/oauth_setup.py\n```\n\nThe script prints an authorization URL. Open it in a browser, log in with your Wahoo account, approve. Wahoo redirects to your callback URL with `?code=…` in the query string — the page itself will fail to load (expected, since nothing's listening there). Copy the redirect URL or just the code value, paste it back into the script. Tokens land at `~/.openclaw/secrets/wahoo_tokens.json` (mode `0600`).\n\n### 5. Sync workouts\n\n```bash\npython3 scripts/fetch_workouts.py\n```\n\nLists every workout, fetches detail per workout (the list endpoint omits `workout_summary`), downloads the FIT into `$WAHOO_TRAINING_DIR/wahoo_fit/`, parses it, and upserts into `$WAHOO_TRAINING_DIR/wahoo.db`. Default `$WAHOO_TRAINING_DIR` is `~/.openclaw/workspace/training/`; override the env var to relocate.\n\nThe pipeline is idempotent — re-run anytime to pick up new workouts. Sandbox rate limits (25 req / 5 min) trigger automatic backoff.\n\n## Layout\n\n```\n.\n├── SKILL.md              # OpenClaw skill manifest (clawhub publish reads this)\n├── README.md\n├── config.example.json\n├── lib/\n│   ├── wahoo_auth.py     # token storage + auto-refresh\n│   ├── wahoo_api.py      # HTTP client (urllib only; no requests dependency)\n│   └── fit_parser.py     # FIT → dict (uses fitparse)\n├── scripts/\n│   ├── oauth_setup.py    # interactive OAuth2 flow\n│   ├── fetch_workouts.py # main sync pipeline\n│   ├── parse_fit.py      # standalone FIT → JSON CLI\n│   └── refresh_token.sh  # manual token refresh\n├── schema/\n│   └── wahoo_db_schema.sql\n└── docs/\n    ├── PRD.md\n    └── PLAN.md\n```\n\n## Database schema\n\n`schema/wahoo_db_schema.sql` defines these tables:\n\n- **`workouts`** — one row per Wahoo workout. Includes both Wahoo-summary fields (`distance_m`, `power_avg`, `heart_rate_avg`, …) and FIT-derived fields (`fit_avg_power_w`, `fit_normalized_power_w`, `fit_record_count`, …).\n- **`records`** — 1 Hz time-series rows per workout: power, cadence, HR, GPS, elevation, speed.\n- **`laps`** — lap splits with aggregate power/HR/distance/duration per lap.\n- **`device_info`** — sensors and devices detected in the FIT file (manufacturer, product, serial, battery status).\n- **`zones`** — power and HR zone thresholds extracted from the FIT session.\n- **`sync_log`** — append-only audit trail of sync runs.\n\nA note on Wahoo's responses: every decimal is returned as a string (`\"power_avg\": \"94.59\"`). The pipeline casts to float on insert; you can read columns as numeric values directly.\n\n## API notes\n\n| Detail | Value |\n|---|---|\n| Base URL | `https://api.wahooligan.com` |\n| Authorize | `GET /oauth/authorize` |\n| Token | `POST /oauth/token` |\n| List workouts | `GET /v1/workouts?page=N&per_page=N` (default 30) |\n| Workout detail | `GET /v1/workouts/:id` (FIT URL at `workout_summary.file.url`) |\n| User profile | `GET /v1/user` |\n| Access token TTL | ~2 hours |\n| Sandbox rate limit | 25 / 5 min, 100 / hr, 250 / day |\n| Production rate limit | 200 / 5 min, 1,000 / hr, 5,000 / day |\n\nThe `workout_type_id` enum's mapping isn't published. Empirically, cycling rides come back as `0`. Treat the field as opaque — filter by the FIT `sport` / `sub_sport` columns instead.\n\nThe list endpoint returns `workout_summary: null` for every entry, so getting a FIT URL requires a per-workout detail call. The sync pipeline batches and rate-limits these for you.\n\nFIT downloads go through Wahoo's CDN, not the API, and don't count against your rate limit.\n\n## Publishing as a ClawHub skill\n\n```bash\nclawhub login\nclawhub publish . \\\n  --slug wahoo-cloud \\\n  --name \"Wahoo Fitness Cloud API\" \\\n  --version 0.1.8 \\\n  --changelog \"v0.1.8: full FIT capture — records, device_info, zones, session/lap gaps\"\n```\n\n`clawhub publish` writes a `_meta.json` with your owner ID, version, and registry pointer. That file is gitignored here; let the CLI manage it.\n\n## Security\n\n- Tokens are stored at `~/.openclaw/secrets/wahoo_tokens.json` with mode `0600`.\n- `client_id` and `client_secret` come from environment variables, or `~/.openclaw/secrets/wahoo.env` (auto-loaded by `wahoo_auth.py` if env is empty), or `~/.clawdbot/clawdbot.json` — never hardcoded in source. Never paste these into agent chat windows or third-party tools; provide them only to local config files you control.\n- FIT files live locally; nothing is uploaded anywhere unless you explicitly add an upload step.\n- `.gitignore` blocks `*.env`, `*.db`, `*.fit`, and `*tokens*.json` — verify before committing if you fork.\n- The local `wahoo.db` and `wahoo_fit/` directory contain GPS, heart-rate, and power history. They inherit your training-dir permissions (default `~/.openclaw/workspace/training/`). Tighten with `chmod 700 ~/.openclaw/workspace/training` if other local users share the machine.\n- `python3 lib/wahoo_auth.py` prints redacted token-file status only (presence, expiry, scope). It never echoes the access or refresh token values.\n\n## Contributing\n\nIssues and PRs welcome, especially:\n\n- Decoding the `workout_type_id` enum across more sports\n- Strava / TrainingPeaks export (would require their respective API integrations)\n- Power-curve / TSS / training-load analytics on top of the FIT records\n- Webhook support for real-time sync (Wahoo supports webhooks; this skill currently polls)\n\n## License\n\nMIT — see [LICENSE.md](LICENSE.md).\n\n## Acknowledgments\n\n- Wahoo Fitness for the API.\n- [`fitparse`](https://github.com/dtcooper/python-fitparse) by David Cooper for the FIT decoder.\n- [OpenClaw](https://openclaw.dev) and [ClawHub](https://clawhub.ai) for the skill runtime.\n\nFile v0.2.0:_meta.json\n\n{\n  \"ownerId\": \"kn705nhwfhwpf3p7cras3v44wd861fwr\",\n  \"slug\": \"wahoo-cloud\",\n  \"version\": \"0.2.0\",\n  \"publishedAt\": 1778700200800\n}\n\nFile v0.2.0:LICENSE.md\n\nMIT License\n\nCopyright (c) 2026 Tyler Merritt\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\nFile v0.2.0:config.example.json\n\n{\n  \"skills\": {\n    \"entries\": {\n      \"wahoo-cloud\": {\n        \"enabled\": true,\n        \"env\": {\n          \"WAHOO_CLIENT_ID\": \"your-client-id-from-developers.wahooligan.com\",\n          \"WAHOO_CLIENT_SECRET\": \"your-client-secret\",\n          \"WAHOO_REDIRECT_URI\": \"https://localhost:8080/\",\n          \"WAHOO_SCOPES\": \"workouts_read offline_data user_read\"\n        }\n      }\n    }\n  }\n}\n\nArchive v0.1.9: 13 files, 25738 bytes\n\nFiles: config.example.json (385b), lib/fit_parser.py (7002b), lib/wahoo_api.py (4615b), lib/wahoo_auth.py (6619b), LICENSE.md (1070b), README.md (8259b), schema/wahoo_db_schema.sql (6328b), scripts/fetch_workouts.py (22207b), scripts/oauth_setup.py (2302b), scripts/parse_fit.py (767b), scripts/refresh_token.sh (1823b), SKILL.md (8401b), _meta.json (130b)\n\nFile v0.1.9:SKILL.md\n\n---\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\nFile v0.1.9:README.md\n\n# Wahoo Cloud API Skill for OpenClaw\n\nAn [OpenClaw](https://openclaw.dev) / [ClawHub](https://clawhub.ai) skill that gives any agent — or any Python script — programmatic access to the [Wahoo Fitness Cloud API](https://cloud-api.wahooligan.com/). Pulls workouts off the Wahoo cloud, downloads the raw FIT files from Wahoo's CDN, parses them with [`fitparse`](https://github.com/dtcooper/python-fitparse), and lands the result in a local SQLite database you control.\n\nWahoo ELEMNT BOLT, ROAM, and ACE generate gold-standard cycling telemetry — power, cadence, heart rate, GPS, elevation, all at 1 Hz — but Wahoo has historically lacked the third-party ecosystem Garmin and Strava enjoy. This skill closes that gap.\n\n## What you get\n\n- **OAuth2 setup helper** — manual-paste flow that works with any registered callback URL, no local HTTPS server required\n- **Auto-refresh** of access tokens via the `offline_data` refresh token\n- **API client** — workout list, workout detail, user profile, with built-in 429 backoff\n- **FIT downloader** — pulls files from Wahoo's CDN (no API rate-limit cost)\n- **FIT parser** — power, cadence, HR, GPS, elevation → JSON / SQLite\n- **SQLite sync pipeline** — incremental, idempotent, safe to re-run\n\n## Status\n\n- API client + parser: working, tested against ~650 real workouts\n- OAuth flow: confidential-app sandbox tested\n- Production approval from Wahoo: not yet submitted\n\n## Quick start\n\n### 1. Register a Wahoo developer app\n\n1. Go to [developers.wahooligan.com](https://developers.wahooligan.com).\n2. Create an application. Sandbox is automatic — no review required.\n3. Set the callback URL (e.g. `https://localhost:8080/`). The OAuth helper uses manual paste, so any URL you can reach in a browser works.\n4. Request scopes. Minimum useful set: `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. Install dependencies\n\n```bash\npip install --user 'fitparse>=1.2,<2'\n```\n\n(`fitparse` is the only Python dependency. `curl` is needed for the shell refresh helper.)\n\n### 3. Configure credentials\n\n```bash\nexport WAHOO_CLIENT_ID=\"...\"\nexport WAHOO_CLIENT_SECRET=\"...\"\nexport WAHOO_REDIRECT_URI=\"https://localhost:8080/\"\nexport WAHOO_SCOPES=\"workouts_read offline_data user_read\"\n```\n\nOr, if you're running inside OpenClaw, add the equivalent block to `~/.clawdbot/clawdbot.json` — see [`config.example.json`](config.example.json).\n\n### 4. Run the OAuth handshake\n\n```bash\npython3 scripts/oauth_setup.py\n```\n\nThe script prints an authorization URL. Open it in a browser, log in with your Wahoo account, approve. Wahoo redirects to your callback URL with `?code=…` in the query string — the page itself will fail to load (expected, since nothing's listening there). Copy the redirect URL or just the code value, paste it back into the script. Tokens land at `~/.openclaw/secrets/wahoo_tokens.json` (mode `0600`).\n\n### 5. Sync workouts\n\n```bash\npython3 scripts/fetch_workouts.py\n```\n\nLists every workout, fetches detail per workout (the list endpoint omits `workout_summary`), downloads the FIT into `$WAHOO_TRAINING_DIR/wahoo_fit/`, parses it, and upserts into `$WAHOO_TRAINING_DIR/wahoo.db`. Default `$WAHOO_TRAINING_DIR` is `~/.openclaw/workspace/training/`; override the env var to relocate.\n\nThe pipeline is idempotent — re-run anytime to pick up new workouts. Sandbox rate limits (25 req / 5 min) trigger automatic backoff.\n\n## Layout\n\n```\n.\n├── SKILL.md              # OpenClaw skill manifest (clawhub publish reads this)\n├── README.md\n├── config.example.json\n├── lib/\n│   ├── wahoo_auth.py     # token storage + auto-refresh\n│   ├── wahoo_api.py      # HTTP client (urllib only; no requests dependency)\n│   └── fit_parser.py     # FIT → dict (uses fitparse)\n├── scripts/\n│   ├── oauth_setup.py    # interactive OAuth2 flow\n│   ├── fetch_workouts.py # main sync pipeline\n│   ├── parse_fit.py      # standalone FIT → JSON CLI\n│   └── refresh_token.sh  # manual token refresh\n├── schema/\n│   └── wahoo_db_schema.sql\n└── docs/\n    ├── PRD.md\n    └── PLAN.md\n```\n\n## Database schema\n\n`schema/wahoo_db_schema.sql` defines these tables:\n\n- **`workouts`** — one row per Wahoo workout. Includes both Wahoo-summary fields (`distance_m`, `power_avg`, `heart_rate_avg`, …) and FIT-derived fields (`fit_avg_power_w`, `fit_normalized_power_w`, `fit_record_count`, …).\n- **`records`** — 1 Hz time-series rows per workout: power, cadence, HR, GPS, elevation, speed.\n- **`laps`** — lap splits with aggregate power/HR/distance/duration per lap.\n- **`device_info`** — sensors and devices detected in the FIT file (manufacturer, product, serial, battery status).\n- **`zones`** — power and HR zone thresholds extracted from the FIT session.\n- **`sync_log`** — append-only audit trail of sync runs.\n\nA note on Wahoo's responses: every decimal is returned as a string (`\"power_avg\": \"94.59\"`). The pipeline casts to float on insert; you can read columns as numeric values directly.\n\n## API notes\n\n| Detail | Value |\n|---|---|\n| Base URL | `https://api.wahooligan.com` |\n| Authorize | `GET /oauth/authorize` |\n| Token | `POST /oauth/token` |\n| List workouts | `GET /v1/workouts?page=N&per_page=N` (default 30) |\n| Workout detail | `GET /v1/workouts/:id` (FIT URL at `workout_summary.file.url`) |\n| User profile | `GET /v1/user` |\n| Access token TTL | ~2 hours |\n| Sandbox rate limit | 25 / 5 min, 100 / hr, 250 / day |\n| Production rate limit | 200 / 5 min, 1,000 / hr, 5,000 / day |\n\nThe `workout_type_id` enum's mapping isn't published. Empirically, cycling rides come back as `0`. Treat the field as opaque — filter by the FIT `sport` / `sub_sport` columns instead.\n\nThe list endpoint returns `workout_summary: null` for every entry, so getting a FIT URL requires a per-workout detail call. The sync pipeline batches and rate-limits these for you.\n\nFIT downloads go through Wahoo's CDN, not the API, and don't count against your rate limit.\n\n## Publishing as a ClawHub skill\n\n```bash\nclawhub login\nclawhub publish . \\\n  --slug wahoo-cloud \\\n  --name \"Wahoo Fitness Cloud API\" \\\n  --version 0.1.8 \\\n  --changelog \"v0.1.8: full FIT capture — records, device_info, zones, session/lap gaps\"\n```\n\n`clawhub publish` writes a `_meta.json` with your owner ID, version, and registry pointer. That file is gitignored here; let the CLI manage it.\n\n## Security\n\n- Tokens are stored at `~/.openclaw/secrets/wahoo_tokens.json` with mode `0600`.\n- `client_id` and `client_secret` come from environment variables, or `~/.openclaw/secrets/wahoo.env` (auto-loaded by `wahoo_auth.py` if env is empty), or `~/.clawdbot/clawdbot.json` — never hardcoded in source. Never paste these into agent chat windows or third-party tools; provide them only to local config files you control.\n- FIT files live locally; nothing is uploaded anywhere unless you explicitly add an upload step.\n- `.gitignore` blocks `*.env`, `*.db`, `*.fit`, and `*tokens*.json` — verify before committing if you fork.\n- The local `wahoo.db` and `wahoo_fit/` directory contain GPS, heart-rate, and power history. They inherit your training-dir permissions (default `~/.openclaw/workspace/training/`). Tighten with `chmod 700 ~/.openclaw/workspace/training` if other local users share the machine.\n- `python3 lib/wahoo_auth.py` prints redacted token-file status only (presence, expiry, scope). It never echoes the access or refresh token values.\n\n## Contributing\n\nIssues and PRs welcome, especially:\n\n- Decoding the `workout_type_id` enum across more sports\n- Strava / TrainingPeaks export (would require their respective API integrations)\n- Power-curve / TSS / training-load analytics on top of the FIT records\n- Webhook support for real-time sync (Wahoo supports webhooks; this skill currently polls)\n\n## License\n\nMIT — see [LICENSE.md](LICENSE.md).\n\n## Acknowledgments\n\n- Wahoo Fitness for the API.\n- [`fitparse`](https://github.com/dtcooper/python-fitparse) by David Cooper for the FIT decoder.\n- [OpenClaw](https://openclaw.dev) and [ClawHub](https://clawhub.ai) for the skill runtime.\n\nFile v0.1.9:_meta.json\n\n{\n  \"ownerId\": \"kn705nhwfhwpf3p7cras3v44wd861fwr\",\n  \"slug\": \"wahoo-cloud\",\n  \"version\": \"0.1.9\",\n  \"publishedAt\": 1778686499222\n}\n\nFile v0.1.9:LICENSE.md\n\nMIT License\n\nCopyright (c) 2026 Tyler Merritt\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\nFile v0.1.9:config.example.json\n\n{\n  \"skills\": {\n    \"entries\": {\n      \"wahoo-cloud\": {\n        \"enabled\": true,\n        \"env\": {\n          \"WAHOO_CLIENT_ID\": \"your-client-id-from-developers.wahooligan.com\",\n          \"WAHOO_CLIENT_SECRET\": \"your-client-secret\",\n          \"WAHOO_REDIRECT_URI\": \"https://localhost:8080/\",\n          \"WAHOO_SCOPES\": \"workouts_read offline_data user_read\"\n        }\n      }\n    }\n  }\n}\n\nArchive v0.1.8: 13 files, 25741 bytes\n\nFiles: config.example.json (385b), lib/fit_parser.py (7002b), lib/wahoo_api.py (4617b), lib/wahoo_auth.py (6619b), LICENSE.md (1070b), README.md (8259b), schema/wahoo_db_schema.sql (6328b), scripts/fetch_workouts.py (22208b), scripts/oauth_setup.py (2302b), scripts/parse_fit.py (767b), scripts/refresh_token.sh (1823b), SKILL.md (8401b), _meta.json (130b)\n\nFile v0.1.8:SKILL.md\n\n---\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\nFile v0.1.8:README.md\n\n# Wahoo Cloud API Skill for OpenClaw\n\nAn [OpenClaw](https://openclaw.dev) / [ClawHub](https://clawhub.ai) skill that gives any agent — or any Python script — programmatic access to the [Wahoo Fitness Cloud API](https://cloud-api.wahooligan.com/). Pulls workouts off the Wahoo cloud, downloads the raw FIT files from Wahoo's CDN, parses them with [`fitparse`](https://github.com/dtcooper/python-fitparse), and lands the result in a local SQLite database you control.\n\nWahoo ELEMNT BOLT, ROAM, and ACE generate gold-standard cycling telemetry — power, cadence, heart rate, GPS, elevation, all at 1 Hz — but Wahoo has historically lacked the third-party ecosystem Garmin and Strava enjoy. This skill closes that gap.\n\n## What you get\n\n- **OAuth2 setup helper** — manual-paste flow that works with any registered callback URL, no local HTTPS server required\n- **Auto-refresh** of access tokens via the `offline_data` refresh token\n- **API client** — workout list, workout detail, user profile, with built-in 429 backoff\n- **FIT downloader** — pulls files from Wahoo's CDN (no API rate-limit cost)\n- **FIT parser** — power, cadence, HR, GPS, elevation → JSON / SQLite\n- **SQLite sync pipeline** — incremental, idempotent, safe to re-run\n\n## Status\n\n- API client + parser: working, tested against ~650 real workouts\n- OAuth flow: confidential-app sandbox tested\n- Production approval from Wahoo: not yet submitted\n\n## Quick start\n\n### 1. Register a Wahoo developer app\n\n1. Go to [developers.wahooligan.com](https://developers.wahooligan.com).\n2. Create an application. Sandbox is automatic — no review required.\n3. Set the callback URL (e.g. `https://localhost:8080/`). The OAuth helper uses manual paste, so any URL you can reach in a browser works.\n4. Request scopes. Minimum useful set: `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. Install dependencies\n\n```bash\npip install --user 'fitparse>=1.2,<2'\n```\n\n(`fitparse` is the only Python dependency. `curl` is needed for the shell refresh helper.)\n\n### 3. Configure credentials\n\n```bash\nexport WAHOO_CLIENT_ID=\"...\"\nexport WAHOO_CLIENT_SECRET=\"...\"\nexport WAHOO_REDIRECT_URI=\"https://localhost:8080/\"\nexport WAHOO_SCOPES=\"workouts_read offline_data user_read\"\n```\n\nOr, if you're running inside OpenClaw, add the equivalent block to `~/.clawdbot/clawdbot.json` — see [`config.example.json`](config.example.json).\n\n### 4. Run the OAuth handshake\n\n```bash\npython3 scripts/oauth_setup.py\n```\n\nThe script prints an authorization URL. Open it in a browser, log in with your Wahoo account, approve. Wahoo redirects to your callback URL with `?code=…` in the query string — the page itself will fail to load (expected, since nothing's listening there). Copy the redirect URL or just the code value, paste it back into the script. Tokens land at `~/.openclaw/secrets/wahoo_tokens.json` (mode `0600`).\n\n### 5. Sync workouts\n\n```bash\npython3 scripts/fetch_workouts.py\n```\n\nLists every workout, fetches detail per workout (the list endpoint omits `workout_summary`), downloads the FIT into `$WAHOO_TRAINING_DIR/wahoo_fit/`, parses it, and upserts into `$WAHOO_TRAINING_DIR/wahoo.db`. Default `$WAHOO_TRAINING_DIR` is `~/.openclaw/workspace/training/`; override the env var to relocate.\n\nThe pipeline is idempotent — re-run anytime to pick up new workouts. Sandbox rate limits (25 req / 5 min) trigger automatic backoff.\n\n## Layout\n\n```\n.\n├── SKILL.md              # OpenClaw skill manifest (clawhub publish reads this)\n├── README.md\n├── config.example.json\n├── lib/\n│   ├── wahoo_auth.py     # token storage + auto-refresh\n│   ├── wahoo_api.py      # HTTP client (urllib only; no requests dependency)\n│   └── fit_parser.py     # FIT → dict (uses fitparse)\n├── scripts/\n│   ├── oauth_setup.py    # interactive OAuth2 flow\n│   ├── fetch_workouts.py # main sync pipeline\n│   ├── parse_fit.py      # standalone FIT → JSON CLI\n│   └── refresh_token.sh  # manual token refresh\n├── schema/\n│   └── wahoo_db_schema.sql\n└── docs/\n    ├── PRD.md\n    └── PLAN.md\n```\n\n## Database schema\n\n`schema/wahoo_db_schema.sql` defines these tables:\n\n- **`workouts`** — one row per Wahoo workout. Includes both Wahoo-summary fields (`distance_m`, `power_avg`, `heart_rate_avg`, …) and FIT-derived fields (`fit_avg_power_w`, `fit_normalized_power_w`, `fit_record_count`, …).\n- **`records`** — 1 Hz time-series rows per workout: power, cadence, HR, GPS, elevation, speed.\n- **`laps`** — lap splits with aggregate power/HR/distance/duration per lap.\n- **`device_info`** — sensors and devices detected in the FIT file (manufacturer, product, serial, battery status).\n- **`zones`** — power and HR zone thresholds extracted from the FIT session.\n- **`sync_log`** — append-only audit trail of sync runs.\n\nA note on Wahoo's responses: every decimal is returned as a string (`\"power_avg\": \"94.59\"`). The pipeline casts to float on insert; you can read columns as numeric values directly.\n\n## API notes\n\n| Detail | Value |\n|---|---|\n| Base URL | `https://api.wahooligan.com` |\n| Authorize | `GET /oauth/authorize` |\n| Token | `POST /oauth/token` |\n| List workouts | `GET /v1/workouts?page=N&per_page=N` (default 30) |\n| Workout detail | `GET /v1/workouts/:id` (FIT URL at `workout_summary.file.url`) |\n| User profile | `GET /v1/user` |\n| Access token TTL | ~2 hours |\n| Sandbox rate limit | 25 / 5 min, 100 / hr, 250 / day |\n| Production rate limit | 200 / 5 min, 1,000 / hr, 5,000 / day |\n\nThe `workout_type_id` enum's mapping isn't published. Empirically, cycling rides come back as `0`. Treat the field as opaque — filter by the FIT `sport` / `sub_sport` columns instead.\n\nThe list endpoint returns `workout_summary: null` for every entry, so getting a FIT URL requires a per-workout detail call. The sync pipeline batches and rate-limits these for you.\n\nFIT downloads go through Wahoo's CDN, not the API, and don't count against your rate limit.\n\n## Publishing as a ClawHub skill\n\n```bash\nclawhub login\nclawhub publish . \\\n  --slug wahoo-cloud \\\n  --name \"Wahoo Fitness Cloud API\" \\\n  --version 0.1.8 \\\n  --changelog \"v0.1.8: full FIT capture — records, device_info, zones, session/lap gaps\"\n```\n\n`clawhub publish` writes a `_meta.json` with your owner ID, version, and registry pointer. That file is gitignored here; let the CLI manage it.\n\n## Security\n\n- Tokens are stored at `~/.openclaw/secrets/wahoo_tokens.json` with mode `0600`.\n- `client_id` and `client_secret` come from environment variables, or `~/.openclaw/secrets/wahoo.env` (auto-loaded by `wahoo_auth.py` if env is empty), or `~/.clawdbot/clawdbot.json` — never hardcoded in source. Never paste these into agent chat windows or third-party tools; provide them only to local config files you control.\n- FIT files live locally; nothing is uploaded anywhere unless you explicitly add an upload step.\n- `.gitignore` blocks `*.env`, `*.db`, `*.fit`, and `*tokens*.json` — verify before committing if you fork.\n- The local `wahoo.db` and `wahoo_fit/` directory contain GPS, heart-rate, and power history. They inherit your training-dir permissions (default `~/.openclaw/workspace/training/`). Tighten with `chmod 700 ~/.openclaw/workspace/training` if other local users share the machine.\n- `python3 lib/wahoo_auth.py` prints redacted token-file status only (presence, expiry, scope). It never echoes the access or refresh token values.\n\n## Contributing\n\nIssues and PRs welcome, especially:\n\n- Decoding the `workout_type_id` enum across more sports\n- Strava / TrainingPeaks export (would require their respective API integrations)\n- Power-curve / TSS / training-load analytics on top of the FIT records\n- Webhook support for real-time sync (Wahoo supports webhooks; this skill currently polls)\n\n## License\n\nMIT — see [LICENSE.md](LICENSE.md).\n\n## Acknowledgments\n\n- Wahoo Fitness for the API.\n- [`fitparse`](https://github.com/dtcooper/python-fitparse) by David Cooper for the FIT decoder.\n- [OpenClaw](https://openclaw.dev) and [ClawHub](https://clawhub.ai) for the skill runtime.\n\nFile v0.1.8:_meta.json\n\n{\n  \"ownerId\": \"kn705nhwfhwpf3p7cras3v44wd861fwr\",\n  \"slug\": \"wahoo-cloud\",\n  \"version\": \"0.1.8\",\n  \"publishedAt\": 1778098407374\n}\n\nFile v0.1.8:LICENSE.md\n\nMIT License\n\nCopyright (c) 2026 Tyler Merritt\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\nFile v0.1.8:config.example.json\n\n{\n  \"skills\": {\n    \"entries\": {\n      \"wahoo-cloud\": {\n        \"enabled\": true,\n        \"env\": {\n          \"WAHOO_CLIENT_ID\": \"your-client-id-from-developers.wahooligan.com\",\n          \"WAHOO_CLIENT_SECRET\": \"your-client-secret\",\n          \"WAHOO_REDIRECT_URI\": \"https://localhost:8080/\",\n          \"WAHOO_SCOPES\": \"workouts_read offline_data user_read\"\n        }\n      }\n    }\n  }\n}\n\nArchive v0.1.4: 14 files, 27004 bytes\n\nFiles: _meta.json (130b), CHANGES_laps_feature.md (11404b), config.example.json (385b), lib/fit_parser.py (5212b), lib/wahoo_api.py (4617b), lib/wahoo_auth.py (6619b), LICENSE.md (1070b), README.md (7880b), schema/wahoo_db_schema.sql (3318b), scripts/fetch_workouts.py (14380b), scripts/oauth_setup.py (2302b), scripts/parse_fit.py (767b), scripts/refresh_token.sh (1823b), SKILL.md (7525b)\n\nFile v0.1.4:SKILL.md\n\n---\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| \"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\nFile v0.1.4:README.md\n\n# Wahoo Cloud API Skill for OpenClaw\n\nAn [OpenClaw](https://openclaw.dev) / [ClawHub](https://clawhub.ai) skill that gives any agent — or any Python script — programmatic access to the [Wahoo Fitness Cloud API](https://cloud-api.wahooligan.com/). Pulls workouts off the Wahoo cloud, downloads the raw FIT files from Wahoo's CDN, parses them with [`fitparse`](https://github.com/dtcooper/python-fitparse), and lands the result in a local SQLite database you control.\n\nWahoo ELEMNT BOLT, ROAM, and ACE generate gold-standard cycling telemetry — power, cadence, heart rate, GPS, elevation, all at 1 Hz — but Wahoo has historically lacked the third-party ecosystem Garmin and Strava enjoy. This skill closes that gap.\n\n## What you get\n\n- **OAuth2 setup helper** — manual-paste flow that works with any registered callback URL, no local HTTPS server required\n- **Auto-refresh** of access tokens via the `offline_data` refresh token\n- **API client** — workout list, workout detail, user profile, with built-in 429 backoff\n- **FIT downloader** — pulls files from Wahoo's CDN (no API rate-limit cost)\n- **FIT parser** — power, cadence, HR, GPS, elevation → JSON / SQLite\n- **SQLite sync pipeline** — incremental, idempotent, safe to re-run\n\n## Status\n\n- API client + parser: working, tested against ~650 real workouts\n- OAuth flow: confidential-app sandbox tested\n- Production approval from Wahoo: not yet submitted\n\n## Quick start\n\n### 1. Register a Wahoo developer app\n\n1. Go to [developers.wahooligan.com](https://developers.wahooligan.com).\n2. Create an application. Sandbox is automatic — no review required.\n3. Set the callback URL (e.g. `https://localhost:8080/`). The OAuth helper uses manual paste, so any URL you can reach in a browser works.\n4. Request scopes. Minimum useful set: `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. Install dependencies\n\n```bash\npip install --user 'fitparse>=1.2,<2'\n```\n\n(`fitparse` is the only Python dependency. `curl` is needed for the shell refresh helper.)\n\n### 3. Configure credentials\n\n```bash\nexport WAHOO_CLIENT_ID=\"...\"\nexport WAHOO_CLIENT_SECRET=\"...\"\nexport WAHOO_REDIRECT_URI=\"https://localhost:8080/\"\nexport WAHOO_SCOPES=\"workouts_read offline_data user_read\"\n```\n\nOr, if you're running inside OpenClaw, add the equivalent block to `~/.clawdbot/clawdbot.json` — see [`config.example.json`](config.example.json).\n\n### 4. Run the OAuth handshake\n\n```bash\npython3 scripts/oauth_setup.py\n```\n\nThe script prints an authorization URL. Open it in a browser, log in with your Wahoo account, approve. Wahoo redirects to your callback URL with `?code=…` in the query string — the page itself will fail to load (expected, since nothing's listening there). Copy the redirect URL or just the code value, paste it back into the script. Tokens land at `~/.openclaw/secrets/wahoo_tokens.json` (mode `0600`).\n\n### 5. Sync workouts\n\n```bash\npython3 scripts/fetch_workouts.py\n```\n\nLists every workout, fetches detail per workout (the list endpoint omits `workout_summary`), downloads the FIT into `$WAHOO_TRAINING_DIR/wahoo_fit/`, parses it, and upserts into `$WAHOO_TRAINING_DIR/wahoo.db`. Default `$WAHOO_TRAINING_DIR` is `~/.openclaw/workspace/training/`; override the env var to relocate.\n\nThe pipeline is idempotent — re-run anytime to pick up new workouts. Sandbox rate limits (25 req / 5 min) trigger automatic backoff.\n\n## Layout\n\n```\n.\n├── SKILL.md              # OpenClaw skill manifest (clawhub publish reads this)\n├── README.md\n├── config.example.json\n├── lib/\n│   ├── wahoo_auth.py     # token storage + auto-refresh\n│   ├── wahoo_api.py      # HTTP client (urllib only; no requests dependency)\n│   └── fit_parser.py     # FIT → dict (uses fitparse)\n├── scripts/\n│   ├── oauth_setup.py    # interactive OAuth2 flow\n│   ├── fetch_workouts.py # main sync pipeline\n│   ├── parse_fit.py      # standalone FIT → JSON CLI\n│   └── refresh_token.sh  # manual token refresh\n├── schema/\n│   └── wahoo_db_schema.sql\n└── docs/\n    ├── PRD.md\n    └── PLAN.md\n```\n\n## Database schema\n\n`schema/wahoo_db_schema.sql` defines two tables:\n\n- **`workouts`** — one row per Wahoo workout. Includes both Wahoo-summary fields (`distance_m`, `power_avg`, `heart_rate_avg`, …) and FIT-derived fields (`fit_avg_power_w`, `fit_normalized_power_w`, `fit_record_count`, …).\n- **`sync_log`** — append-only audit trail of sync runs.\n\nA note on Wahoo's responses: every decimal is returned as a string (`\"power_avg\": \"94.59\"`). The pipeline casts to float on insert; you can read columns as numeric values directly.\n\n## API notes\n\n| Detail | Value |\n|---|---|\n| Base URL | `https://api.wahooligan.com` |\n| Authorize | `GET /oauth/authorize` |\n| Token | `POST /oauth/token` |\n| List workouts | `GET /v1/workouts?page=N&per_page=N` (default 30) |\n| Workout detail | `GET /v1/workouts/:id` (FIT URL at `workout_summary.file.url`) |\n| User profile | `GET /v1/user` |\n| Access token TTL | ~2 hours |\n| Sandbox rate limit | 25 / 5 min, 100 / hr, 250 / day |\n| Production rate limit | 200 / 5 min, 1,000 / hr, 5,000 / day |\n\nThe `workout_type_id` enum's mapping isn't published. Empirically, cycling rides come back as `0`. Treat the field as opaque — filter by the FIT `sport` / `sub_sport` columns instead.\n\nThe list endpoint returns `workout_summary: null` for every entry, so getting a FIT URL requires a per-workout detail call. The sync pipeline batches and rate-limits these for you.\n\nFIT downloads go through Wahoo's CDN, not the API, and don't count against your rate limit.\n\n## Publishing as a ClawHub skill\n\n```bash\nclawhub login\nclawhub publish . \\\n  --slug wahoo-cloud \\\n  --name \"Wahoo Fitness Cloud API\" \\\n  --version 0.1.0 \\\n  --changelog \"Initial release: OAuth2, workout fetch, FIT download/parse, SQLite sync\"\n```\n\n`clawhub publish` writes a `_meta.json` with your owner ID, version, and registry pointer. That file is gitignored here; let the CLI manage it.\n\n## Security\n\n- Tokens are stored at `~/.openclaw/secrets/wahoo_tokens.json` with mode `0600`.\n- `client_id` and `client_secret` come from environment variables, or `~/.openclaw/secrets/wahoo.env` (auto-loaded by `wahoo_auth.py` if env is empty), or `~/.clawdbot/clawdbot.json` — never hardcoded in source. Never paste these into agent chat windows or third-party tools; provide them only to local config files you control.\n- FIT files live locally; nothing is uploaded anywhere unless you explicitly add an upload step.\n- `.gitignore` blocks `*.env`, `*.db`, `*.fit`, and `*tokens*.json` — verify before committing if you fork.\n- The local `wahoo.db` and `wahoo_fit/` directory contain GPS, heart-rate, and power history. They inherit your training-dir permissions (default `~/.openclaw/workspace/training/`). Tighten with `chmod 700 ~/.openclaw/workspace/training` if other local users share the machine.\n- `python3 lib/wahoo_auth.py` prints redacted token-file status only (presence, expiry, scope). It never echoes the access or refresh token values.\n\n## Contributing\n\nIssues and PRs welcome, especially:\n\n- Decoding the `workout_type_id` enum across more sports\n- Strava / TrainingPeaks export (would require their respective API integrations)\n- Power-curve / TSS / training-load analytics on top of the FIT records\n- Webhook support for real-time sync (Wahoo supports webhooks; this skill currently polls)\n\n## License\n\nMIT — see [LICENSE.md](LICENSE.md).\n\n## Acknowledgments\n\n- Wahoo Fitness for the API.\n- [`fitparse`](https://github.com/dtcooper/python-fitparse) by David Cooper for the FIT decoder.\n- [OpenClaw](https://openclaw.dev) and [ClawHub](https://clawhub.ai) for the skill runtime.\n\nFile v0.1.4:_meta.json\n\n{\n  \"ownerId\": \"kn705nhwfhwpf3p7cras3v44wd861fwr\",\n  \"slug\": \"wahoo-cloud\",\n  \"version\": \"0.1.4\",\n  \"publishedAt\": 1778034244450\n}\n\nFile v0.1.4:CHANGES_laps_feature.md\n\n# Lap Splits Feature — Implementation Guide\n\n## Overview\n\nAdded lap-split extraction from FIT files into the local Wahoo SQLite database. Every synced workout that contains lap data in its FIT file now has per-lap records stored in a new `laps` table, including power zone time distributions, cadence, speed, grade, and altitude data.\n\n---\n\n## Changes Summary\n\n| File | Type | What Changed |\n|------|------|--------------|\n| `lib/fit_parser.py` | Modified | Added `_iter_laps()`, `_f_safe()`, and `laps` key to parse return |\n| `scripts/fetch_workouts.py` | Modified | Added `_iso()`, `store_laps()`, and hook into `store_fit_parse()` |\n| `schema/wahoo_db_schema.sql` | Modified | Added `laps` table + index |\n\n---\n\n## 1. `lib/fit_parser.py`\n\n### Added: `_iter_laps(fit)` generator\n\n```python\ndef _iter_laps(fit) -> Iterator[dict]:\n    \"\"\"Extract lap data from FIT file.\"\"\"\n    for msg in fit.get_messages(\"lap\"):\n        d: dict[str, Any] = {}\n        tz = None\n        for f in msg.fields:\n            if f.name == \"time_in_power_zone\":\n                v = f.value\n                if isinstance(v, tuple) and len(v) == 6:\n                    tz = [float(x) for x in v]\n                elif isinstance(v, int):\n                    tz = [float(v)] + [0.0]*5\n                continue\n            v = f.value\n            if f.name == \"timestamp\":\n                d[\"end_time\"] = _iso(v)\n            elif f.name == \"start_time\":\n                d[\"start_time\"] = _iso(v)\n            else:\n                d[f.name] = _f_safe(v)\n        if tz:\n            d[\"time_in_zone1\"] = tz[0]\n            d[\"time_in_zone2\"] = tz[1]\n            d[\"time_in_zone3\"] = tz[2]\n            d[\"time_in_zone4\"] = tz[3]\n            d[\"time_in_zone5\"] = tz[4]\n            d[\"time_in_zone6\"] = tz[5]\n        else:\n            d[\"time_in_zone1\"] = None\n            # ... (all 6 zone fields)\n        yield d\n```\n\n**Key details:**\n- Iterates FIT lap messages (not session, not record)\n- `time_in_power_zone` is a `FieldData` object — extract `.value` which is a 6-element tuple of seconds per power zone (Z1–Z6)\n- Uses `_f_safe()` for generic value extraction (see below)\n- Handles missing zone data gracefully (fills `None`)\n\n### Added: `_f_safe(v)` helper\n\n```python\ndef _f_safe(v) -> float | None:\n    \"\"\"Safely convert a FIT field value to float.\"\"\"\n    if v is None:\n        return None\n    try:\n        return float(v)\n    except (TypeError, ValueError):\n        return None\n```\n\n**Why it exists:** `_f()` is designed for session dict access (key-based). Lap fields come from FIT `FieldData` objects where the value may already be a raw type or wrapped in `FieldData`. `_f_safe` handles both cases.\n\n### Modified: `parse()` return structure\n\nThe `parse()` function now includes `\"laps\"` in its return dict:\n\n```python\ndef parse(path: str | Path) -> dict[str, Any]:\n    fitparse = _ensure_fitparse()\n    fit = fitparse.FitFile(str(path))\n    session = _first_session(fit)\n    records = list(_iter_records(fit))\n    lap_data = list(_iter_laps(fit))    # <-- NEW\n\n    summary = { ... }  # unchanged\n\n    return {\"summary\": summary, \"records\": records, \"laps\": lap_data}  # <-- NEW\n```\n\n**Important:** The `parse()` function is backwards-compatible. Callers that only use `parsed[\"summary\"]` or `parsed[\"records\"]` will continue to work. New callers access `parsed[\"laps\"]`.\n\n---\n\n## 2. `scripts/fetch_workouts.py`\n\n### Added: `_iso(dt)` helper\n\n```python\ndef _iso(dt) -> str | None:\n    if dt is None:\n        return None\n    try:\n        return dt.isoformat() if hasattr(dt, 'isoformat') else str(dt)\n    except Exception:\n        return str(dt)\n```\n\nUsed by `store_laps()` to convert FIT timestamps to ISO8601 strings for SQLite storage.\n\n### Added: `store_laps(conn, workout_id, laps)` function\n\n```python\ndef store_laps(conn, workout_id, laps):\n    if not laps:\n        return\n    cur = conn.cursor()\n    cur.execute(\"DELETE FROM laps WHERE workout_id = ?\", (workout_id,))  # idempotent re-parse\n    for i, lap in enumerate(laps):\n        cur.execute(\"\"\"\n            INSERT INTO laps (\n                workout_id, lap_number, start_time, end_time,\n                elapsed_s, timer_s, distance_m, ascent_m, descent_m,\n                calories, work_j, avg_power_w, np_w, max_power_w,\n                avg_cadence, max_cadence, avg_speed_ms, max_speed_ms,\n                avg_grade, max_pos_grade, max_neg_grade,\n                avg_altitude, max_altitude, min_altitude,\n                avg_temperature, max_temperature, left_right_balance,\n                time_in_zone1, time_in_zone2, time_in_zone3,\n                time_in_zone4, time_in_zone5, time_in_zone6\n            ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)\n        \"\"\", (\n            workout_id, i+1,\n            _iso(lap.get(\"start_time\")),\n            _iso(lap.get(\"end_time\")),\n            _to_float(lap.get(\"total_elapsed_time\")),\n            _to_float(lap.get(\"total_timer_time\")),\n            _to_float(lap.get(\"total_distance\")),\n            _to_float(lap.get(\"total_ascent\")),\n            _to_float(lap.get(\"total_descent\")),\n            _to_float(lap.get(\"total_calories\")),\n            _to_float(lap.get(\"total_work\")),\n            _to_float(lap.get(\"avg_power\")),        # FIT field name, NOT \"average_power\"\n            _to_float(lap.get(\"normalized_power\")),\n            _to_float(lap.get(\"max_power\")),\n            _to_float(lap.get(\"avg_cadence\")),\n            _to_float(lap.get(\"max_cadence\")),\n            _to_float(lap.get(\"avg_speed\")),\n            _to_float(lap.get(\"max_speed\")),\n            _to_float(lap.get(\"avg_grade\")),\n            _to_float(lap.get(\"max_pos_grade\")),\n            _to_float(lap.get(\"max_neg_grade\")),\n            _to_float(lap.get(\"avg_altitude\")),\n            _to_float(lap.get(\"enhanced_max_altitude\")),\n            _to_float(lap.get(\"enhanced_min_altitude\")),\n            _to_float(lap.get(\"avg_temperature\")),\n            _to_float(lap.get(\"max_temperature\")),\n            _to_float(lap.get(\"left_right_balance\")),\n            _to_float(lap.get(\"time_in_zone1\")),\n            _to_float(lap.get(\"time_in_zone2\")),\n            _to_float(lap.get(\"time_in_zone3\")),\n            _to_float(lap.get(\"time_in_zone4\")),\n            _to_float(lap.get(\"time_in_zone5\")),\n            _to_float(lap.get(\"time_in_zone6\")),\n        ))\n    conn.commit()\n```\n\n**Critical FIT field names to note:**\n- `avg_power` — NOT `average_power` (FIT standard)\n- `enhanced_max_altitude` / `enhanced_min_altitude` — use enhanced variants when available\n- `time_in_power_zone` — already unpacked in `fit_parser.py` as 6 separate zone keys\n\n**Idempotency:** The function `DELETE`s existing laps for this `workout_id` before re-inserting. This means re-parsing a FIT file (e.g., after a schema change) is safe.\n\n### Modified: `store_fit_parse()` hook\n\nAt the end of `store_fit_parse()`, after updating the `workouts` table, add:\n\n```python\n# Also store lap splits\nstore_laps(conn, workout_id, parsed.get(\"laps\", []))\n```\n\nThis ensures every FIT parse populates laps automatically. No separate invocation needed.\n\n---\n\n## 3. `schema/wahoo_db_schema.sql`\n\n### Added: `laps` table\n\n```sql\nCREATE TABLE IF NOT EXISTS laps (\n    workout_id INTEGER,\n    lap_number INTEGER,\n    start_time TEXT,\n    end_time TEXT,\n    elapsed_s REAL,\n    timer_s REAL,\n    distance_m REAL,\n    ascent_m REAL,\n    descent_m REAL,\n    calories REAL,\n    work_j REAL,\n    avg_power_w REAL,\n    np_w REAL,\n    max_power_w REAL,\n    avg_cadence REAL,\n    max_cadence REAL,\n    avg_speed_ms REAL,\n    max_speed_ms REAL,\n    avg_grade REAL,\n    max_pos_grade REAL,\n    max_neg_grade REAL,\n    avg_altitude REAL,\n    max_altitude REAL,\n    min_altitude REAL,\n    avg_temperature REAL,\n    max_temperature REAL,\n    left_right_balance REAL,\n    time_in_zone1 REAL,\n    time_in_zone2 REAL,\n    time_in_zone3 REAL,\n    time_in_zone4 REAL,\n    time_in_zone5 REAL,\n    time_in_zone6 REAL,\n    FOREIGN KEY (workout_id) REFERENCES workouts(id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_laps_workout ON laps(workout_id);\n```\n\n**Placement:** Append after the `sync_log` table definition.\n\n**Notes:**\n- `IF NOT EXISTS` — safe to re-run on existing DBs\n- `lap_number` is 1-indexed (matches FIT order)\n- Zone columns are seconds spent in each power zone (1=Watts <55%, 2=55-75%, 3=75-85%, 4=85-95%, 5=95-110%, 6=>110% of FTP)\n\n---\n\n## 4. Existing DB Migration (one-shot)\n\nFor the existing DB at `~/.openclaw/workspace/training/wahoo.db`, run:\n\n```sql\nCREATE TABLE IF NOT EXISTS laps ( ... );  -- from schema above\nCREATE INDEX IF NOT EXISTS idx_laps_workout ON laps(workout_id);\n```\n\nThen for existing workouts, re-parse their FIT files:\n\n```python\n# Python one-liner for existing workouts\nimport fit_parser, sqlite3, sys\nfrom pathlib import Path\n\nconn = sqlite3.connect(Path.home() / \".openclaw/workspace/training/wahoo.db\")\nconn.execute(\"SELECT id, fit_path FROM workouts WHERE fit_parsed_at IS NOT NULL AND fit_path IS NOT NULL\")\nfor wid, fpath in conn.fetchall():\n    p = fit_parser.parse(fpath)\n    if p.get(\"laps\"):\n        from scripts.fetch_workouts import store_laps\n        store_laps(conn, wid, p[\"laps\"])\nconn.commit()\nconn.close()\nprint(\"Done.\")\n```\n\nThis backfills laps for any workouts already parsed before the feature was added.\n\n---\n\n## 5. Verification Queries\n\nCheck laps are populated:\n\n```sql\n-- Count laps per workout\nSELECT workout_id, COUNT(*) AS lap_count\nFROM laps\nGROUP BY workout_id\nORDER BY lap_count DESC;\n\n-- Full lap breakdown for a workout\nSELECT lap_number,\n       elapsed_s/60 AS minutes,\n       distance_m/1000 AS km,\n       avg_power_w, np_w, max_power_w,\n       avg_cadence, max_cadence,\n       avg_speed_ms*3.6 AS kmh,\n       time_in_zone4 + time_in_zone5 + time_in_zone6 AS hard_effort_s\nFROM laps\nWHERE workout_id = 453261873\nORDER BY lap_number;\n\n-- Power zone distribution across all hard efforts\nSELECT\n    SUM(time_in_zone4) AS z4_total_s,\n    SUM(time_in_zone5) AS z5_total_s,\n    SUM(time_in_zone6) AS z6_total_s\nFROM laps;\n```\n\n---\n\n## 6. Integration Points\n\n| Component | Depends On |\n|-----------|-----------|\n| `fetch_workouts.py` | `fit_parser.py` (imports it) |\n| `fit_parser.py` | `fitparse` package (external dep) |\n| `parse_fit.py` (standalone) | `fit_parser.py` (can now accept `--laps` flag) |\n\nNo changes needed to `wahoo_api.py` or `wahoo_auth.py` — this is purely a post-download parse step.\n\n---\n\n## 7. Edge Cases & Gotchas\n\n1. **FIT files without laps** — Some FIT files have zero lap records. `parse()` returns `laps: []`. `store_laps()` handles this gracefully (returns early, no-op).\n\n2. **FIT files with sessions but no laps** — Rare but possible. Same handling: empty list.\n\n3. **`time_in_power_zone` field** — Only present if a power meter was connected AND the ride was logged with power zones enabled. Most rides from Wahoo/zwift have this. If absent, all 6 zone columns are `NULL`.\n\n4. **FieldData wrapping** — FIT values come wrapped in `fitparse.records.FieldData` objects. Always access `.value` for the actual Python type. The `_f_safe()` and `_iso()` helpers handle this transparently.\n\n5. **Re-parsing** — The `DELETE + INSERT` pattern in `store_laps()` means you can safely call `fetch_workouts.py` multiple times on the same workout without duplicate laps.\n\n---\n\n_Last updated: 2026-05-05_\n_Author: Puck (via fit_parser.py, fetch_workouts.py, wahoo_db_schema.sql)_\n\nFile v0.1.4:LICENSE.md\n\nMIT License\n\nCopyright (c) 2026 Tyler Merritt\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\nFile v0.1.4:config.example.json\n\n{\n  \"skills\": {\n    \"entries\": {\n      \"wahoo-cloud\": {\n        \"enabled\": true,\n        \"env\": {\n          \"WAHOO_CLIENT_ID\": \"your-client-id-from-developers.wahooligan.com\",\n          \"WAHOO_CLIENT_SECRET\": \"your-client-secret\",\n          \"WAHOO_REDIRECT_URI\": \"https://localhost:8080/\",\n          \"WAHOO_SCOPES\": \"workouts_read offline_data user_read\"\n        }\n      }\n    }\n  }\n}\n\nArchive v0.1.3: 13 files, 21558 bytes\n\nFiles: config.example.json (385b), lib/fit_parser.py (3681b), lib/wahoo_api.py (4575b), lib/wahoo_auth.py (6619b), LICENSE.md (1070b), README.md (7880b), schema/wahoo_db_schema.sql (2360b), scripts/fetch_workouts.py (11123b), scripts/oauth_setup.py (2302b), scripts/parse_fit.py (767b), scripts/refresh_token.sh (1823b), SKILL.md (7525b), _meta.json (130b)\n\nFile v0.1.3:SKILL.md\n\n---\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| \"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\nFile v0.1.3:README.md\n\n# Wahoo Cloud API Skill for OpenClaw\n\nAn [OpenClaw](https://openclaw.dev) / [ClawHub](https://clawhub.ai) skill that gives any agent — or any Python script — programmatic access to the [Wahoo Fitness Cloud API](https://cloud-api.wahooligan.com/). Pulls workouts off the Wahoo cloud, downloads the raw FIT files from Wahoo's CDN, parses them with [`fitparse`](https://github.com/dtcooper/python-fitparse), and lands the result in a local SQLite database you control.\n\nWahoo ELEMNT BOLT, ROAM, and ACE generate gold-standard cycling telemetry — power, cadence, heart rate, GPS, elevation, all at 1 Hz — but Wahoo has historically lacked the third-party ecosystem Garmin and Strava enjoy. This skill closes that gap.\n\n## What you get\n\n- **OAuth2 setup helper** — manual-paste flow that works with any registered callback URL, no local HTTPS server required\n- **Auto-refresh** of access tokens via the `offline_data` refresh token\n- **API client** — workout list, workout detail, user profile, with built-in 429 backoff\n- **FIT downloader** — pulls files from Wahoo's CDN (no API rate-limit cost)\n- **FIT parser** — power, cadence, HR, GPS, elevation → JSON / SQLite\n- **SQLite sync pipeline** — incremental, idempotent, safe to re-run\n\n## Status\n\n- API client + parser: working, tested against ~650 real workouts\n- OAuth flow: confidential-app sandbox tested\n- Production approval from Wahoo: not yet submitted\n\n## Quick start\n\n### 1. Register a Wahoo developer app\n\n1. Go to [developers.wahooligan.com](https://developers.wahooligan.com).\n2. Create an application. Sandbox is automatic — no review required.\n3. Set the callback URL (e.g. `https://localhost:8080/`). The OAuth helper uses manual paste, so any URL you can reach in a browser works.\n4. Request scopes. Minimum useful set: `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. Install dependencies\n\n```bash\npip install --user 'fitparse>=1.2,<2'\n```\n\n(`fitparse` is the only Python dependency. `curl` is needed for the shell refresh helper.)\n\n### 3. Configure credentials\n\n```bash\nexport WAHOO_CLIENT_ID=\"...\"\nexport WAHOO_CLIENT_SECRET=\"...\"\nexport WAHOO_REDIRECT_URI=\"https://localhost:8080/\"\nexport WAHOO_SCOPES=\"workouts_read offline_data user_read\"\n```\n\nOr, if you're running inside OpenClaw, add the equivalent block to `~/.clawdbot/clawdbot.json` — see [`config.example.json`](config.example.json).\n\n### 4. Run the OAuth handshake\n\n```bash\npython3 scripts/oauth_setup.py\n```\n\nThe script prints an authorization URL. Open it in a browser, log in with your Wahoo account, approve. Wahoo redirects to your callback URL with `?code=…` in the query string — the page itself will fail to load (expected, since nothing's listening there). Copy the redirect URL or just the code value, paste it back into the script. Tokens land at `~/.openclaw/secrets/wahoo_tokens.json` (mode `0600`).\n\n### 5. Sync workouts\n\n```bash\npython3 scripts/fetch_workouts.py\n```\n\nLists every workout, fetches detail per workout (the list endpoint omits `workout_summary`), downloads the FIT into `$WAHOO_TRAINING_DIR/wahoo_fit/`, parses it, and upserts into `$WAHOO_TRAINING_DIR/wahoo.db`. Default `$WAHOO_TRAINING_DIR` is `~/.openclaw/workspace/training/`; override the env var to relocate.\n\nThe pipeline is idempotent — re-run anytime to pick up new workouts. Sandbox rate limits (25 req / 5 min) trigger automatic backoff.\n\n## Layout\n\n```\n.\n├── SKILL.md              # OpenClaw skill manifest (clawhub publish reads this)\n├── README.md\n├── config.example.json\n├── lib/\n│   ├── wahoo_auth.py     # token storage + auto-refresh\n│   ├── wahoo_api.py      # HTTP client (urllib only; no requests dependency)\n│   └── fit_parser.py     # FIT → dict (uses fitparse)\n├── scripts/\n│   ├── oauth_setup.py    # interactive OAuth2 flow\n│   ├── fetch_workouts.py # main sync pipeline\n│   ├── parse_fit.py      # standalone FIT → JSON CLI\n│   └── refresh_token.sh  # manual token refresh\n├── schema/\n│   └── wahoo_db_schema.sql\n└── docs/\n    ├── PRD.md\n    └── PLAN.md\n```\n\n## Database schema\n\n`schema/wahoo_db_schema.sql` defines two tables:\n\n- **`workouts`** — one row per Wahoo workout. Includes both Wahoo-summary fields (`distance_m`, `power_avg`, `heart_rate_avg`, …) and FIT-derived fields (`fit_avg_power_w`, `fit_normalized_power_w`, `fit_record_count`, …).\n- **`sync_log`** — append-only audit trail of sync runs.\n\nA note on Wahoo's responses: every decimal is returned as a string (`\"power_avg\": \"94.59\"`). The pipeline casts to float on insert; you can read columns as numeric values directly.\n\n## API notes\n\n| Detail | Value |\n|---|---|\n| Base URL | `https://api.wahooligan.com` |\n| Authorize | `GET /oauth/authorize` |\n| Token | `POST /oauth/token` |\n| List workouts | `GET /v1/workouts?page=N&per_page=N` (default 30) |\n| Workout detail | `GET /v1/workouts/:id` (FIT URL at `workout_summary.file.url`) |\n| User profile | `GET /v1/user` |\n| Access token TTL | ~2 hours |\n| Sandbox rate limit | 25 / 5 min, 100 / hr, 250 / day |\n| Production rate limit | 200 / 5 min, 1,000 / hr, 5,000 / day |\n\nThe `workout_type_id` enum's mapping isn't published. Empirically, cycling rides come back as `0`. Treat the field as opaque — filter by the FIT `sport` / `sub_sport` columns instead.\n\nThe list endpoint returns `workout_summary: null` for every entry, so getting a FIT URL requires a per-workout detail call. The sync pipeline batches and rate-limits these for you.\n\nFIT downloads go through Wahoo's CDN, not the API, and don't count against your rate limit.\n\n## Publishing as a ClawHub skill\n\n```bash\nclawhub login\nclawhub publish . \\\n  --slug wahoo-cloud \\\n  --name \"Wahoo Fitness Cloud API\" \\\n  --version 0.1.0 \\\n  --changelog \"Initial release: OAuth2, workout fetch, FIT download/parse, SQLite sync\"\n```\n\n`clawhub publish` writes a `_meta.json` with your owner ID, version, and registry pointer. That file is gitignored here; let the CLI manage it.\n\n## Security\n\n- Tokens are stored at `~/.openclaw/secrets/wahoo_tokens.json` with mode `0600`.\n- `client_id` and `client_secret` come from environment variables, or `~/.openclaw/secrets/wahoo.env` (auto-loaded by `wahoo_auth.py` if env is empty), or `~/.clawdbot/clawdbot.json` — never hardcoded in source. Never paste these into agent chat windows or third-party tools; provide them only to local config files you control.\n- FIT files live locally; nothing is uploaded anywhere unless you explicitly add an upload step.\n- `.gitignore` blocks `*.env`, `*.db`, `*.fit`, and `*tokens*.json` — verify before committing if you fork.\n- The local `wahoo.db` and `wahoo_fit/` directory contain GPS, heart-rate, and power history. They inherit your training-dir permissions (default `~/.openclaw/workspace/training/`). Tighten with `chmod 700 ~/.openclaw/workspace/training` if other local users share the machine.\n- `python3 lib/wahoo_auth.py` prints redacted token-file status only (presence, expiry, scope). It never echoes the access or refresh token values.\n\n## Contributing\n\nIssues and PRs welcome, especially:\n\n- Decoding the `workout_type_id` enum across more sports\n- Strava / TrainingPeaks export (would require their respective API integrations)\n- Power-curve / TSS / training-load analytics on top of the FIT records\n- Webhook support for real-time sync (Wahoo supports webhooks; this skill currently polls)\n\n## License\n\nMIT — see [LICENSE.md](LICENSE.md).\n\n## Acknowledgments\n\n- Wahoo Fitness for the API.\n- [`fitparse`](https://github.com/dtcooper/python-fitparse) by David Cooper for the FIT decoder.\n- [OpenClaw](https://openclaw.dev) and [ClawHub](https://clawhub.ai) for the skill runtime.\n\nFile v0.1.3:_meta.json\n\n{\n  \"ownerId\": \"kn705nhwfhwpf3p7cras3v44wd861fwr\",\n  \"slug\": \"wahoo-cloud\",\n  \"version\": \"0.1.3\",\n  \"publishedAt\": 1777854016768\n}\n\nFile v0.1.3:LICENSE.md\n\nMIT License\n\nCopyright (c) 2026 Tyler Merritt\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\nFile v0.1.3:config.example.json\n\n{\n  \"skills\": {\n    \"entries\": {\n      \"wahoo-cloud\": {\n        \"enabled\": true,\n        \"env\": {\n          \"WAHOO_CLIENT_ID\": \"your-client-id-from-developers.wahooligan.com\",\n          \"WAHOO_CLIENT_SECRET\": \"your-client-secret\",\n          \"WAHOO_REDIRECT_URI\": \"https://localhost:8080/\",\n          \"WAHOO_SCOPES\": \"workouts_read offline_data user_read\"\n        }\n      }\n    }\n  }\n}\n\nArchive v0.1.2: 15 files, 27462 bytes\n\nFiles: config.example.json (385b), docs/PLAN.md (5435b), docs/PRD.md (8367b), lib/fit_parser.py (3671b), lib/wahoo_api.py (4575b), lib/wahoo_auth.py (5922b), LICENSE.md (1070b), README.md (7243b), schema/wahoo_db_schema.sql (2360b), scripts/fetch_workouts.py (11123b), scripts/oauth_setup.py (2302b), scripts/parse_fit.py (767b), scripts/refresh_token.sh (1823b), SKILL.md (7525b), _meta.json (130b)\n\nFile v0.1.2:SKILL.md\n\n---\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| \"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\nFile v0.1.2:README.md\n\n# Wahoo Cloud API Skill for OpenClaw\n\nAn [OpenClaw](https://openclaw.dev) / [ClawHub](https://clawhub.ai) skill that gives any agent — or any Python script — programmatic access to the [Wahoo Fitness Cloud API](https://cloud-api.wahooligan.com/). Pulls workouts off the Wahoo cloud, downloads the raw FIT files from Wahoo's CDN, parses them with [`fitparse`](https://github.com/dtcooper/python-fitparse), and lands the result in a local SQLite database you control.\n\nWahoo ELEMNT BOLT, ROAM, and ACE generate gold-standard cycling telemetry — power, cadence, heart rate, GPS, elevation, all at 1 Hz — but Wahoo has historically lacked the third-party ecosystem Garmin and Strava enjoy. This skill closes that gap.\n\n## What you get\n\n- **OAuth2 setup helper** — manual-paste flow that works with any registered callback URL, no local HTTPS server required\n- **Auto-refresh** of access tokens via the `offline_data` refresh token\n- **API client** — workout list, workout detail, user profile, with built-in 429 backoff\n- **FIT downloader** — pulls files from Wahoo's CDN (no API rate-limit cost)\n- **FIT parser** — power, cadence, HR, GPS, elevation → JSON / SQLite\n- **SQLite sync pipeline** — incremental, idempotent, safe to re-run\n\n## Status\n\n- API client + parser: working, tested against ~650 real workouts\n- OAuth flow: confidential-app sandbox tested\n- Production approval from Wahoo: not yet submitted\n\n## Quick start\n\n### 1. Register a Wahoo developer app\n\n1. Go to [developers.wahooligan.com](https://developers.wahooligan.com).\n2. Create an application. Sandbox is automatic — no review required.\n3. Set the callback URL (e.g. `https://localhost:8080/`). The OAuth helper uses manual paste, so any URL you can reach in a browser works.\n4. Request scopes. Minimum useful set: `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. Install dependencies\n\n```bash\npip install --user fitparse\n```\n\n(`fitparse` is the only Python dependency. `curl` is needed for the shell refresh helper.)\n\n### 3. Configure credentials\n\n```bash\nexport WAHOO_CLIENT_ID=\"...\"\nexport WAHOO_CLIENT_SECRET=\"...\"\nexport WAHOO_REDIRECT_URI=\"https://localhost:8080/\"\nexport WAHOO_SCOPES=\"workouts_read offline_data user_read\"\n```\n\nOr, if you're running inside OpenClaw, add the equivalent block to `~/.clawdbot/clawdbot.json` — see [`config.example.json`](config.example.json).\n\n### 4. Run the OAuth handshake\n\n```bash\npython3 scripts/oauth_setup.py\n```\n\nThe script prints an authorization URL. Open it in a browser, log in with your Wahoo account, approve. Wahoo redirects to your callback URL with `?code=…` in the query string — the page itself will fail to load (expected, since nothing's listening there). Copy the redirect URL or just the code value, paste it back into the script. Tokens land at `~/.openclaw/secrets/wahoo_tokens.json` (mode `0600`).\n\n### 5. Sync workouts\n\n```bash\npython3 scripts/fetch_workouts.py\n```\n\nLists every workout, fetches detail per workout (the list endpoint omits `workout_summary`), downloads the FIT into `$WAHOO_TRAINING_DIR/wahoo_fit/`, parses it, and upserts into `$WAHOO_TRAINING_DIR/wahoo.db`. Default `$WAHOO_TRAINING_DIR` is `~/.openclaw/workspace/training/`; override the env var to relocate.\n\nThe pipeline is idempotent — re-run anytime to pick up new workouts. Sandbox rate limits (25 req / 5 min) trigger automatic backoff.\n\n## Layout\n\n```\n.\n├── SKILL.md              # OpenClaw skill manifest (clawhub publish reads this)\n├── README.md\n├── config.example.json\n├── lib/\n│   ├── wahoo_auth.py     # token storage + auto-refresh\n│   ├── wahoo_api.py      # HTTP client (urllib only; no requests dependency)\n│   └── fit_parser.py     # FIT → dict (uses fitparse)\n├── scripts/\n│   ├── oauth_setup.py    # interactive OAuth2 flow\n│   ├── fetch_workouts.py # main sync pipeline\n│   ├── parse_fit.py      # standalone FIT → JSON CLI\n│   └── refresh_token.sh  # manual token refresh\n├── schema/\n│   └── wahoo_db_schema.sql\n└── docs/\n    ├── PRD.md\n    └── PLAN.md\n```\n\n## Database schema\n\n`schema/wahoo_db_schema.sql` defines two tables:\n\n- **`workouts`** — one row per Wahoo workout. Includes both Wahoo-summary fields (`distance_m`, `power_avg`, `heart_rate_avg`, …) and FIT-derived fields (`fit_avg_power_w`, `fit_normalized_power_w`, `fit_record_count`, …).\n- **`sync_log`** — append-only audit trail of sync runs.\n\nA note on Wahoo's responses: every decimal is returned as a string (`\"power_avg\": \"94.59\"`). The pipeline casts to float on insert; you can read columns as numeric values directly.\n\n## API notes\n\n| Detail | Value |\n|---|---|\n| Base URL | `https://api.wahooligan.com` |\n| Authorize | `GET /oauth/authorize` |\n| Token | `POST /oauth/token` |\n| List workouts | `GET /v1/workouts?page=N&per_page=N` (default 30) |\n| Workout detail | `GET /v1/workouts/:id` (FIT URL at `workout_summary.file.url`) |\n| User profile | `GET /v1/user` |\n| Access token TTL | ~2 hours |\n| Sandbox rate limit | 25 / 5 min, 100 / hr, 250 / day |\n| Production rate limit | 200 / 5 min, 1,000 / hr, 5,000 / day |\n\nThe `workout_type_id` enum's mapping isn't published. Empirically, cycling rides come back as `0`. Treat the field as opaque — filter by the FIT `sport` / `sub_sport` columns instead.\n\nThe list endpoint returns `workout_summary: null` for every entry, so getting a FIT URL requires a per-workout detail call. The sync pipeline batches and rate-limits these for you.\n\nFIT downloads go through Wahoo's CDN, not the API, and don't count against your rate limit.\n\n## Publishing as a ClawHub skill\n\n```bash\nclawhub login\nclawhub publish . \\\n  --slug wahoo-cloud \\\n  --name \"Wahoo Fitness Cloud API\" \\\n  --version 0.1.0 \\\n  --changelog \"Initial release: OAuth2, workout fetch, FIT download/parse, SQLite sync\"\n```\n\n`clawhub publish` writes a `_meta.json` with your owner ID, version, and registry pointer. That file is gitignored here; let the CLI manage it.\n\n## Security\n\n- Tokens are stored at `~/.openclaw/secrets/wahoo_tokens.json` with mode `0600`.\n- `client_id` and `client_secret` come from environment variables (or `~/.clawdbot/clawdbot.json`) — never hardcoded in source.\n- FIT files live locally; nothing is uploaded anywhere unless you explicitly add an upload step.\n- `.gitignore` blocks `*.env`, `*.db`, `*.fit`, and `*tokens*.json` — verify before committing if you fork.\n\n## Contributing\n\nIssues and PRs welcome, especially:\n\n- Decoding the `workout_type_id` enum across more sports\n- Strava / TrainingPeaks export (would require their respective API integrations)\n- Power-curve / TSS / training-load analytics on top of the FIT records\n- Webhook support for real-time sync (Wahoo supports webhooks; this skill currently polls)\n\n## License\n\nMIT — see [LICENSE.md](LICENSE.md).\n\n## Acknowledgments\n\n- Wahoo Fitness for the API.\n- [`fitparse`](https://github.com/dtcooper/python-fitparse) by David Cooper for the FIT decoder.\n- [OpenClaw](https://openclaw.dev) and [ClawHub](https://clawhub.ai) for the skill runtime.\n\nFile v0.1.2:_meta.json\n\n{\n  \"ownerId\": \"kn705nhwfhwpf3p7cras3v44wd861fwr\",\n  \"slug\": \"wahoo-cloud\",\n  \"version\": \"0.1.2\",\n  \"publishedAt\": 1777853692588\n}\n\nFile v0.1.2:docs/PLAN.md\n\n# Wahoo Developer Account — Setup Plan\n\n## Phase 1: Developer Registration (Do This Now)\n\n### 1.1 Create Wahoo Developer Account\n- Go to `https://developers.wahooligan.com`\n- Sign up with your Wahoo/Fitline account (or create one)\n- This is the portal where you register applications\n\n### 1.2 Register Application (Sandbox)\n- In the Developer Portal, create a new application\n- Fill in:\n  - **App Name:** OpenClaw Wahoo Skill (or \"Puck Training Tools\")\n  - **Description:** OpenClaw integration for Wahoo Fitness Cloud API. Provides programmatic access to workout data, FIT file download, and cycling analytics.\n  - **Redirect URI:** `http://localhost:8080/callback` (local OAuth2 callback)\n  - **Scopes:** `workouts_read` (minimum), `workouts_write` (for upload capability)\n  - **App Type:** Sandbox (this is automatic — no review needed)\n\n### 1.3 Get Credentials\n- After creating the app, you'll get:\n  - `client_id` — unique identifier for your app\n  - `client_secret` — secret key for your app\n- **Store these in OpenClaw secrets:**\n  ```bash\n  openclaw secrets set wahoo.client_id \"...\"\n  openclaw secrets set wahoo.client_secret \"...\"\n  ```\n\n### 1.4 Complete OAuth2 Authorization Flow\n- Open the authorization URL in a browser:\n  ```\n  https://id.wahoo.com/auth/oauth2/v1/authorize\n    ?response_type=code\n    &client_id=<client_id>\n    &redirect_uri=http://localhost:8080/callback\n    &scope=workouts_read\n  ```\n- Log in with your Wahoo account\n- Approve the permissions\n- You'll be redirected to `http://localhost:8080/callback?code=<AUTH_CODE>`\n- Exchange the auth code for an access token:\n  ```\n  POST https://id.wahoo.com/auth/oauth2/v1/token\n    Content-Type: application/x-www-form-urlencoded\n  \n    grant_type=authorization_code\n    &code=<AUTH_CODE>\n    &client_id=<client_id>\n    &client_secret=<client_secret>\n    &redirect_uri=http://localhost:8080/callback\n  ```\n- Response includes:\n  - `access_token` — use for all API calls (short-lived)\n  - `refresh_token` — use to get new access tokens (long-lived)\n  - `expires_in` — seconds until token expires\n\n### 1.5 Store Tokens\n```bash\nopenclaw secrets set wahoo.access_token \"...\"\nopenclaw secrets set wahoo.refresh_token \"...\"\n```\n\n## Phase 2: Build the Skill (After Approval)\n\n### 2.1 Scaffold the Skill\n```bash\nmkdir -p skills/wahoo/{lib,scripts}\n```\n\n### 2.2 Implement Core Components\n- `wahoo_auth.py` — OAuth2 token management + auto-refresh\n- `wahoo_api.py` — API client with all endpoints\n- `fit_parser.py` — FIT file parsing using `fitparse` library\n\n### 2.3 Implement Scripts\n- `fetch_workouts.py` — fetch + download + parse + DB insert\n- `parse_fit.py` — standalone FIT parser for ad-hoc analysis\n\n### 2.4 Write SKILL.md\n- Define the skill's capabilities, parameters, and usage patterns\n- Follow ClawHub skill format\n\n### 2.5 Test Thoroughly\n- Test with real Wahoo data (at least 5-10 rides)\n- Verify FIT parsing accuracy against Strava data\n- Test OAuth2 refresh flow\n- Test error handling (rate limits, expired tokens, network errors)\n\n## Phase 3: Production Approval\n\n### 3.1 Submit for Production\n- In Developer Portal, switch app from Sandbox to Production\n- Provide:\n  - **App Description** — expanded, professional\n  - **Use Case** — explain the training analytics tool\n  - **Data Usage** — describe how data is stored and used\n  - **Privacy Policy** — basic statement about data handling\n- Wahoo will review (timeline unknown, could be days to weeks)\n\n### 3.2 If Approved\n- Continue as-is, now with production access\n- Publish to ClawHub\n\n### 3.3 If Rejected\n- Stay in Sandbox (it works, just labeled \"test\")\n- Iterate on the submission\n- Document the limitation clearly\n\n## Phase 4: ClawHub Publishing\n\n### 4.1 Prepare for Publish\n- Finalize README.md\n- Ensure SKILL.md is complete\n- Add config.example.json\n- Write test scripts\n\n### 4.2 Publish\n```bash\ncd skills/wahoo\nclawhub publish . --slug wahoo --name \"Wahoo Fitness Cloud API\" --version 1.0.0 --changelog \"Initial Wahoo Cloud API integration: OAuth2 auth, workout fetch, FIT file download/parse, local DB sync\"\n```\n\n---\n\n## What Tyler Needs To Do\n\n1. **Go to developers.wahooligan.com** — create account if needed\n2. **Register a Sandbox app** — fill in the form (takes 5 minutes)\n3. **Give me the client_id and client_secret** — I'll handle the OAuth2 flow\n4. **Test ride** — go on a ride, let it sync to Wahoo cloud, then I'll pull it down\n\n## What I'll Do\n\n1. **Handle the OAuth2 flow** — generate auth URL, exchange tokens, store credentials\n2. **Build the skill** — all Python code, parsing, DB integration\n3. **Write documentation** — SKILL.md, README, setup guide\n4. **Test everything** — with real data from your Wahoo\n5. **Submit for production** — handle the review process\n6. **Publish to ClawHub** — once approved\n\n## Estimated Timeline\n\n| Step | Time |\n|------|------|\n| Developer registration | 10 min (Tyler) |\n| OAuth2 setup + testing | 30 min (me) |\n| Skill prototype | 2-3 days |\n| Real data testing | 1 day |\n| Production submission | 1 hour |\n| Production approval | Wahoo decides (could be fast, could be slow) |\n| ClawHub publish | 1 day after approval |\n\n---\n\n*Note: The Sandbox app works identically to Production — it's just a flag on Wahoo's side. We can build and test everything in Sandbox. Production approval is only needed if we want to publish the skill publicly or if Wahoo changes sandbox limitations.*\n\nFile v0.1.2:docs/PRD.md\n\n# ClawHub Skill: Wahoo Fitness Cloud API Integration\n\n**Version:** 0.1.0 (Draft)\n**Author:** Puck / Tyler Merritt\n**Status:** Pre-Development — Wahoo Developer Approval Pending\n\n---\n\n## Problem Statement\n\nWahoo ELEMNT head units (BOLT, ROAM, ACE) generate high-quality cycling data: power, cadence, heart rate, speed, GPS, elevation — all stored in FIT files. But there's no programmatic way to access that data. The only official API is the Wahoo Fitness Cloud API, which requires a developer application review and OAuth2 authentication.\n\nMeanwhile, Garmin has a thriving third-party ecosystem. Wahoo has nothing. This leaves Wahoo users in a data black hole — their ride data lives in the Wahoo app, the Wahoo cloud, or Strava/Komoot, but never in their own hands via a tool they control.\n\n## Goal\n\nBuild a ClawHub skill that gives OpenClaw agents full programmatic access to the Wahoo Fitness Cloud API, enabling:\n\n1. **Data retrieval** — fetch ride metadata and download FIT files\n2. **Data ingestion** — parse FIT files and populate a local training database\n3. **Data export** — upload FIT files to third-party platforms (Strava, etc.)\n4. **Health analytics** — training load, power analysis, recovery tracking\n\n## API Overview\n\nThe Wahoo Fitness Cloud API (wahooligan) is an OAuth2 REST API with these endpoints:\n\n### Authentication\n- OAuth2 Authorization Code flow\n- `client_id` + `client_secret` from Wahoo Developer Portal\n- `access_token` for API calls\n- `workouts_read` and `workouts_write` scopes\n- App registration: Sandbox → Production (requires review)\n\n### Endpoints\n\n| Endpoint | Method | Description |\n|----------|--------|-------------|\n| `/auth/token` | POST | Exchange auth code for access token |\n| `/api/v1/user/profile` | GET | User profile, zones, settings |\n| `/api/v1/user/workouts` | GET | List workouts (metadata, file URLs) |\n| `/api/v1/user/workouts/{id}` | GET | Workout detail |\n| `/api/v1/user/workouts` | POST | Upload FIT file |\n| `/api/v1/user/workouts/{id}/file` | GET | Download FIT file |\n| `/api/v1/user/health` | GET | Health metrics (if available) |\n\n### FIT File Download\n\nWorkout objects include a `file` field with a URL to download the raw FIT file. This is the gold — every power curve, cadence spike, heart rate zone, and GPS track.\n\n## Skill Architecture\n\n### Files\n\n```\nskills/wahoo/\n├── SKILL.md              # OpenClaw skill definition\n├── lib/\n│   ├── wahoo_auth.py     # OAuth2 token management\n│   ├── wahoo_api.py      # API client (requests-based)\n│   └── fit_parser.py     # FIT file parser (fitparse library)\n├── scripts/\n│   ├── fetch_workouts.py # Fetch workout list + download FIT files\n│   ├── parse_fit.py      # Parse FIT file → JSON/CSV\n│   └── upload_strava.py  # Upload FIT to Strava (optional)\n├── config.example.json   # Template for credentials\n└── README.md             # Setup + usage docs\n```\n\n### Core Functions\n\n1. **`wahoo_auth.py`**\n   - Store `client_id`, `client_secret` securely (OpenClaw secrets)\n   - Handle OAuth2 flow: generate auth URL, exchange code for token, refresh token\n   - Token storage: encrypted file or OpenClaw secret store\n   - Auto-refresh before expiry\n\n2. **`wahoo_api.py`**\n   - Base API client with auth header injection\n   - `get_workouts(page, per_page)` — list workouts\n   - `get_workout(id)` — workout detail\n   - `download_workout_file(id)` — download FIT file\n   - `upload_workout(file_path)` — upload FIT to Wahoo cloud\n   - `get_profile()` — user profile data\n   - Rate limiting, retry logic, error handling\n\n3. **`fit_parser.py`**\n   - Use `fitparse` library (Python FIT file parser)\n   - Parse FIT → structured JSON\n   - Extract: power, cadence, HR, speed, elevation, GPS, time, calories\n   - Output formats: JSON, CSV, SQLite insert\n\n4. **`fetch_workouts.py`**\n   - Main fetch script (analogous to `fetch_strava.py`)\n   - Fetch all workouts (paginated, safety limit)\n   - Download FIT files for each workout\n   - Parse and upsert into local SQLite DB\n   - Sync log for tracking\n\n5. **`upload_strava.py`** (optional)\n   - Upload FIT files to Strava via Strava API\n   - Duplicate detection (skip if already synced)\n   - Configurable target (Strava, TrainingPeaks, etc.)\n\n### Integration with Existing Pipeline\n\nThe Wahoo skill feeds into the same `~/.openclaw/workspace/training/strava.db` schema, or a separate `wahoo.db`. This means:\n\n- **One training DB** or **Two parallel DBs** — depends on Tyler's preference\n- Training analytics (TSS, IF, zone compliance) work across both sources\n- Weekly report can pull from either or both DBs\n- If Wahoo data is richer (HR + power simultaneously), it could become the primary source\n\n## User Workflow\n\n### First Setup\n1. Register developer app at `https://developers.wahooligan.com`\n2. Get `client_id` and `client_secret` (Sandbox for testing)\n3. Install skill: `clawhub install wahoo` (or `clawhub install <our-slug>`)\n4. Configure credentials in skill config\n5. Run OAuth2 flow (skill opens browser or provides URL for Tyler to authorize)\n6. Store access token\n7. Run `fetch_workouts.py` — initial data pull\n\n### Daily/Weekly\n- Skill runs on demand or via heartbeat\n- Fetches new workouts since last sync\n- Downloads and parses FIT files\n- Updates local database\n- Generates training summary\n\n### On Request\n- Tyler: \"Show me my power curve from last Saturday's ride\"\n- Tyler: \"Upload my Wahoo ride to Strava\"\n- Tyler: \"What was my avg power last week?\"\n- Tyler: \"Download my FIT file from the Hammer ride\"\n\n## Security Considerations\n\n- OAuth2 credentials stored in OpenClaw secrets (not in plaintext)\n- Access tokens have expiry — auto-refresh mechanism\n- FIT files are local only — not exposed to any API unless Tyler explicitly requests upload\n- Skill follows OpenClaw sandbox model\n- No Wahoo credentials shared with any agent other than Puck\n\n## Success Metrics\n\n1. **Developer approval** — Wahoo reviews and approves production app\n2. **Data sync** — all Wahoo workouts synced to local DB\n3. **FIT parsing** — accurate extraction of power, cadence, HR, GPS\n4. **Skill quality** — passes ClawHub review, documented, tested\n5. **Adoption** — other Wahoo users install and benefit\n\n## Competitive Positioning\n\n| Platform | Third-Party API | ClawHub Skill | Data Export |\n|----------|----------------|---------------|-------------|\n| Strava   | ✅ Full API     | ✅ Strava skill | ✅ FIT download |\n| Garmin   | ✅ Full API     | ⏳ Pending      | ✅ FIT download |\n| Wahoo    | ⚠️ OAuth2 only  | 🔨 **Building**  | ⚠️ Cloud only |\n| Zwift    | ❌ No API       | —             | ❌ Closed |\n\nWahoo is the last major cycling platform without broad third-party tooling. This skill fills that gap.\n\n## Timeline\n\n| Phase | Action | ETA |\n|-------|--------|-----|\n| 1 | Register Wahoo developer app | Immediate |\n| 2 | Sandbox approval (automatic) | 1-2 days |\n| 3 | Build skill prototype (fetch + parse) | 1 week |\n| 4 | Test with real Wahoo data | 1 week |\n| 5 | Submit for production approval | Week 3 |\n| 6 | Production review (Wahoo decides timeline) | TBD |\n| 7 | Publish to ClawHub | After production approval |\n\n## Open Questions\n\n1. **DB strategy** — One DB for Strava + Wahoo, or separate?\n2. **Upload direction** — Wahoo → Strava, or also Strava → Wahoo?\n3. **Health endpoint** — Does the Wahoo API actually expose health data, or just workouts?\n4. **Token storage** — OpenClaw secrets, encrypted file, or keyring?\n5. **CLI vs tool** — Skill with scripts, or full OpenClaw tool?\n6. **FIT file parsing** — `fitparse` library? Or write our own parser?\n7. **Sync frequency** — On-demand, daily heartbeat, or continuous?\n8. **Pricing** — ClawHub free, paid, or tip-based?\n\n## Risk Assessment\n\n| Risk | Likelihood | Impact | Mitigation |\n|------|-----------|--------|------------|\n| Wahoo rejects production app | Medium | High | Stay in Sandbox, document clearly |\n| API changes without notice | Low | Medium | Version pinning, monitoring |\n| OAuth2 flow complexity | Medium | Low | Document clearly, provide CLI helpers |\n| FIT file format changes | Low | Low | `fitparse` handles most cases |\n| Rate limiting | Low | Medium | Implement backoff, cache responses |\n\n---\n\n*This PRD is a living document. It will evolve as we build.*\n\nFile v0.1.2:LICENSE.md\n\nMIT License\n\nCopyright (c) 2026 Tyler Merritt\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\nFile v0.1.2:config.example.json\n\n{\n  \"skills\": {\n    \"entries\": {\n      \"wahoo-cloud\": {\n        \"enabled\": true,\n        \"env\": {\n          \"WAHOO_CLIENT_ID\": \"your-client-id-from-developers.wahooligan.com\",\n          \"WAHOO_CLIENT_SECRET\": \"your-client-secret\",\n          \"WAHOO_REDIRECT_URI\": \"https://localhost:8080/\",\n          \"WAHOO_SCOPES\": \"workouts_read offline_data user_read\"\n        }\n      }\n    }\n  }\n}\n\nArchive v0.1.1: 15 files, 26853 bytes\n\nFiles: config.example.json (385b), docs/PLAN.md (5435b), docs/PRD.md (8367b), lib/fit_parser.py (3671b), lib/wahoo_api.py (4575b), lib/wahoo_auth.py (4547b), LICENSE.md (1070b), README.md (7243b), schema/wahoo_db_schema.sql (2360b), scripts/fetch_workouts.py (11123b), scripts/oauth_setup.py (2302b), scripts/parse_fit.py (767b), scripts/refresh_token.sh (1823b), SKILL.md (7187b), _meta.json (130b)\n\nFile v0.1.1:SKILL.md\n\n---\nname: wahoo-cloud\ndescription: Wahoo Fitness Cloud API — fetch workouts,\n\nArchive v0.1.0: 14 files, 26103 bytes\n\nFiles: config.example.json (385b), docs/PLAN.md (5435b), docs/PRD.md (8367b), lib/fit_parser.py (3671b), lib/wahoo_api.py (4575b), lib/wahoo_auth.py (4547b), README.md (7237b), schema/wahoo_db_schema.sql (2360b), scripts/fetch_workouts.py (11123b), scripts/oauth_setup.py (2302b), scripts/parse_fit.py (767b), scripts/refresh_token.sh (1823b), SKILL.md (7187b), _meta.json (130b)","readmeExcerpt":"Skill: Wahoo Fitness Cloud API Owner: tgmerritt Summary: Access Wahoo Fitness Cloud API to fetch workouts, download FIT files, and analyze training data (power, HR, cadence, GPS). Tags: cycling:0.1.3, fit:0.1.3, fitness:0.1.3, latest:0.2.1, oauth2:0.1.3, strava-alternative:0.1.3, wahoo:0.1.3 Version history: v0.2.1 | 2026-05-26T20:45:31.856Z | user Make skill runtime-agnostic: align WAHOO_BASE_DIR defaults, remove Op","codeSnippets":[],"executableExamples":[{"language":"bash","snippet":"pip install -r requirements.txt"},{"language":"bash","snippet":"export WAHOO_CLIENT_ID='your_id'\n   export WAHOO_CLIENT_SECRET='your_secret'"},{"language":"bash","snippet":"python3 scripts/oauth_setup.py"},{"language":"json","snippet":"{\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}"},{"language":"bash","snippet":"export WAHOO_CLIENT_ID=\"...\"\nexport WAHOO_CLIENT_SECRET=\"...\"\nexport WAHOO_REDIRECT_URI=\"https://localhost:8080/\""},{"language":"bash","snippet":"python3 {baseDir}/scripts/oauth_setup.py"}],"parameters":null,"dependencies":[],"permissions":[],"extractedFiles":[{"path":"SKILL.md","content":"---\nname: wahoo-cloud\ndescription: Access Wahoo Fitness Cloud API to fetch workouts, download FIT files, and analyze training data (power, HR, cadence, GPS).\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 Skill\n\nProvides programmatic access to Wahoo Fitness Cloud data. This skill manages OAuth2 authentication, workout synchronization, and FIT file processing.\n\n## 🚀 Agent Quickstart\n\nWhen a user asks about their Wahoo workouts, training, or fitness data, use these workflows:\n\n| User Intent | Action / Command |\n| :--- | :--- |\n| **\"Sync my Wahoo workouts\"** | `python3 {baseDir}/scripts/fetch_workouts.py` |\n| **\"Show my recent rides\"** | Query the local SQLite DB: `$WAHOO_BASE_DIR/wahoo.db (default: ~/.wahoo/wahoo.db)` — schema below |\n| **\"Parse this FIT file\"** | `python3 {baseDir}/scripts/parse_fit.py <PATH_TO_FIT>` |\n| **\"Connect/Set up Wahoo\"** | 1. Verify `WAHOO_CLIENT_ID` and `WAHOO_CLIENT_SECRET` exist.<br>2. If missing, ask user to provide them.<br>3. Run `python3 {baseDir}/scripts/oauth_setup.py` |\n| **\"Token expired / persistent 401 errors\"** | Run python3 {baseDir}/scripts/oauth_setup.py to re-authorize. Force-refresh without browser: python3 -c \"import sys; sys.path.insert(0,'lib'); import wahoo_auth; wahoo_auth.refresh()\" |\n\n## 🛠 Key Workflows\n\n### 1. Authentication & Setup\nThis skill requires a Wahoo Developer App. \n- **Credentials:** Must be provided as `WAHOO_CLIENT_ID` and `WAHOO_CLIENT_SECRET`.\n- **OAuth Flow:** If tokens are missing or expired, run `scripts/oauth_setup.py`. This is an **interactive** process. You must present the URL to the user, wait for them to authenticate, and then ask them to paste the resulting redirect URL/code back into the terminal.\n- **Token Storage:** Tokens are stored at $WAHOO_BASE_DIR/secrets/wahoo_tokens.json (controlled by WAHOO_BASE_DIR env var, default ~/.wahoo).\n\n### 2. Data Synchronization\n- **Command:** `python3 scripts/fetch_workouts.py`\n- **Behavior:** Idempotent. It fetches new workouts, downloads FIT files, and populates the local SQLite database.\n- **Database Path:** `$WAHOO_BASE_DIR/wahoo.db (default: ~/.wahoo/wahoo.db)`\n- **Rate Limits:** The script automatically handles Wahoo's sandbox rate limits (25 req / 5 min) using exponential backoff.\n\n### 3. Querying Training Data\nOnce synced, use the following SQL patterns to answer user questions:\n\n- **Recent Workouts:** `SELECT * FROM workouts ORDER BY starts DESC LIMIT 10;`\n- **Power/HR Series:** `SELECT timestamp, power_w, heart_rate, cadence FROM records WHERE workout_id = <id> ORDER BY timestamp;`\n- **Elevation/GPS:** `SELECT timestamp, position_lat_deg, position_long_deg, enhanced_altitude_m FROM records WHERE workout_id = <id>;`\n\n## ⚠️ Critical Constraints & Pitfalls\n\n- **Rate Limiting:** Wahoo Sandbox is strict. Do not attempt to loop calls rapidly; rely on the `wahoo_ap"},{"path":"README.md","content":"# Wahoo Skill\n\nAn agentic skill for synchronizing Wahoo fitness data into a local SQLite database. Designed to be high-signal for LLM agents and comprehensive for human developers.\n\n## 🚀 Agent Quickstart (High-Signal)\n\nUse these commands to interact with Wahoo data.\n\n### Core Capabilities\n- **Sync Data**: `python3 scripts/fetch_workouts.py [--limit N]`\n  - Fetches workout metadata and downloads/parses FIT files.\n  - `--limit N`: Only process the N most recent workouts (useful for testing/rate-limit avoidance).\n- **Auth Setup**: `python3 scripts/oauth_setup.py`\n  - Interactive OAuth2 flow to generate access and refresh tokens.\n\n### Environment Variables\n| Variable | Description | Default |\n| :--- | :--- | :--- |\n| `WAHOO_BASE_DIR` | Base directory for all Wahoo files (secrets, DB, FITs) | `~/.wahoo` |\n| `WAHOO_CLIENT_ID` | Wahoo API Client ID | *Required* |\n| `WAHOO_CLIENT_SECRET` | Wahoo API Client Secret | *Required* |\n\n### Data Schema (SQLite)\nThe data is stored in `[WAHOO_BASE_DIR]/wahoo.db`.\n\n**Key Tables:**\n- `workouts`: Metadata (name, starts, distance, duration, power, heart rate, etc.)\n- `laps`: Detailed lap-by-lap breakdown of each workout.\n- `records`: High-resolution sensor data (power, heart rate, cadence, GPS).\n- `device_info`: Hardware details used during the workout.\n- `zones`: Heart rate and power zone data.\n- `sync_log`: History of synchronization runs.\n\n---\n\n## 🛠 Developer Guide (Human-Centric)\n\n## Overview\nThis skill provides a bridge between the Wahoo API and a local, structured SQLite database. It handles the complexities of OAuth2 token refreshing, paginated API requests, FIT file downloading, and intensive data parsing.\n\n## Architecture\n\n### 1. Authentication Flow\nThe skill uses the OAuth2 Authorization Code flow.\n- **Tokens Location**: `[WAHOO_BASE_DIR]/secrets/wahoo_tokens.json`\n- **Environment File**: `[WAHOO_BASE_DIR]/secrets/wahoo.env` (Optional, used to load credentials).\n\n### 2. Sync Workflow\nThe `fetch_workouts.py` script follows this pipeline:\n1. **Auth**: Ensures a valid access token is available (refreshes if necessary).\n2. **Discovery**: Iterates through `/v1/workouts` to find new or updated metadata.\n3. **Metadata Upsert**: Updates the `workouts` table with the latest summary data.\n4. **FIT Processing**: \n   - If a workout lacks a local FIT file or parsed data:\n   - Downloads the `.fit` file from the `fit_url` provided by Wahoo.\n   - Uses `fit_parser` to extract high-resolution metrics.\n   - Populates `laps`, `records`, `device_info`, and `zones` tables.\n\n### 3. Database Schema\nThe schema is managed via `schema/wahoo_db_schema.sql` and incremental migrations defined in `scripts/fetch_workouts.py`.\n\n## Installation & Setup\n\n1. **Clone the repository.**\n2. **Install dependencies**:\n   ```bash\n   pip install -r requirements.txt\n   ```\n3. **Configure Credentials**:\n   Create a `.env` file in your `WAHOO_BASE_DIR` or set them in your shell:\n   ```bash\n   export WAHOO_CLIENT_ID='your_id'\n   export WAHOO_CLIENT_SECR"},{"path":"_meta.json","content":"{\n  \"ownerId\": \"kn705nhwfhwpf3p7cras3v44wd861fwr\",\n  \"slug\": \"wahoo-cloud\",\n  \"version\": \"0.2.1\",\n  \"publishedAt\": 1779828331856\n}"},{"path":"LICENSE.md","content":"MIT License\n\nCopyright (c) 2026 Tyler Merritt\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."},{"path":"skill-card.md","content":"## Description:\n\nAccess Wahoo Fitness Cloud API to fetch workouts, download FIT files, and analyze training data (power, HR, cadence, GPS).\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[tgmerritt](https://clawhub.ai/user/tgmerritt)\n\n### License/Terms of Use:\n\nMIT\n\n## Use Case:\n\nDevelopers and agents use this skill to connect to Wahoo Fitness Cloud, synchronize workout data, parse FIT files, and answer training questions from a local SQLite database.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: OAuth tokens and detailed workout or GPS data may be exposed on shared machines, synced folders, or agent workspaces.\n\nMitigation: Use a private WAHOO_BASE_DIR with restrictive permissions and avoid placing the database, FIT files, or token files in shared locations.\n\nRisk: OAuth setup output and logs can reveal sensitive authentication details.\n\nMitigation: Avoid logging OAuth setup sessions or sharing terminal output from authorization and token setup.\n\nRisk: The default OAuth scopes may be broader than a user's immediate task requires.\n\nMitigation: Reduce WAHOO_SCOPES where possible before authorizing the skill.\n\nRisk: Dependency installation may be fragile because the dependency name and version are not pinned in requirements.txt.\n\nMitigation: Fix and pin the FIT parsing dependency before running pip install in a production or shared environment.\n\n## Reference(s):\n\n- [Wahoo Fitness Cloud API](https://cloud-api.wahooligan.com/)\n- [ClawHub Skill Page](https://clawhub.ai/tgmerritt/skills/wahoo-cloud)\n\n## Skill Output:\n\n**Output Type(s):** [text, markdown, code, shell commands, configuration, guidance]\n\n**Output Format:** [Markdown with shell commands, SQL examples, configuration guidance, and local file paths]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [May create or update local OAuth token files, FIT downloads, and a SQLite workout database when the documented commands are run.]\n\n## Skill Version(s):\n\n0.2.1 (source: server-resolved release evidence)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment."}],"languages":[],"docsSourceLabel":"CLAWHUB","editorialOverview":null,"editorialQuality":{"score":100,"threshold":65,"status":"thin","wordCount":1836,"uniquenessScore":44,"reasons":["uniqueness-below-45"]}},"media":{"evidence":{"source":"no-media","verified":false,"confidence":"low","updatedAt":"2026-10-11T02:53:13.526Z","emptyReason":"No screenshots, media assets, or demo links are available."},"primaryImageUrl":null,"mediaAssetCount":0,"assets":[],"demoUrl":null},"ownerResources":{"evidence":{"source":"unclaimed","verified":false,"confidence":"low","updatedAt":"2026-10-11T02:53:13.526Z","emptyReason":"This page has not been claimed by the agent owner."},"hasCustomPage":false,"customPageUpdatedAt":null,"customLinks":[],"structuredLinks":{"docsUrl":null,"demoUrl":null,"supportUrl":null,"pricingUrl":null,"statusUrl":null},"customPage":null},"relatedAgents":{"evidence":{"source":"protocol-neighbors","verified":false,"confidence":"medium","updatedAt":"2026-10-11T05:32:09.150Z","emptyReason":null},"items":[{"id":"8ebccd8e-3863-4187-8355-c3f14e1f9edf","entityType":"agent","canonicalPath":"/agent/iofficeai-aionui","slug":"iofficeai-aionui","name":"AionUi","description":"Free, local, open-source 24/7 Cowork app and OpenClaw for Gemini CLI, Claude Code, Codex, OpenCode, Qwen Code, Goose CLI, Auggie, and more | 🌟 Star if you like it!","url":"https://github.com/iOfficeAI/AionUi","homepage":"https://www.aionui.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-10-09T19:11:12.944Z","createdAt":"2026-02-25T03:38:16.584Z","downloads":null},{"id":"b917f68a-ebff-438e-84f8-3f4b2494c0bc","entityType":"agent","canonicalPath":"/agent/activepieces-activepieces","slug":"activepieces-activepieces","name":"activepieces","description":"AI Agents & MCPs & AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows & AI Agents • MCPs for AI Agents","url":"https://github.com/activepieces/activepieces","homepage":"https://www.activepieces.com","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-15T02:22:12.426Z","createdAt":"2026-02-25T03:38:12.412Z","downloads":null},{"id":"5cb26759-3a39-483f-94cf-276a98c13bb8","entityType":"agent","canonicalPath":"/agent/cherryhq-cherry-studio","slug":"cherryhq-cherry-studio","name":"cherry-studio","description":"AI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs","url":"https://github.com/CherryHQ/cherry-studio","homepage":"https://cherry-ai.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-11T14:38:40.986Z","createdAt":"2026-02-25T03:38:19.379Z","downloads":null},{"id":"6f6582d0-5d76-4f0f-b81d-86520247950b","entityType":"agent","canonicalPath":"/agent/copilotkit-copilotkit","slug":"copilotkit-copilotkit","name":"CopilotKit","description":"The Frontend for Agents & Generative UI. React + Angular","url":"https://github.com/CopilotKit/CopilotKit","homepage":"https://docs.copilotkit.ai","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-03-25T09:50:57.846Z","createdAt":"2026-02-25T03:39:14.617Z","downloads":null}],"links":{"hub":"/agent","source":"/agent/source/clawhub","protocols":[{"label":"OpenClaw","href":"/agent/protocol/openclew"}]}}}