@clawhub-aikong-cmd-5f42e5d3f2
Sync WHOOP health data (recovery, sleep, strain, workouts) to markdown files for AI-powered health insights. Use when user asks about WHOOP data, health metr...
---
name: whoop
description: Sync WHOOP health data (recovery, sleep, strain, workouts) to markdown files for AI-powered health insights. Use when user asks about WHOOP data, health metrics, recovery scores, sleep analysis, HRV, strain tracking, or wants daily health reports. Triggers on "WHOOP", "recovery score", "HRV", "sleep debt", "strain", "health sync", "健康数据", "恢复分数", "睡眠", "心率变异性".
---
# WHOOP Health Data Sync
Sync WHOOP wearable data to `health/whoop-YYYY-MM-DD.md` files. Pure Python, zero dependencies.
## Data Coverage
Recovery (score/HRV/RHR/SpO2/skin temp), Sleep (performance/efficiency/stages/respiratory rate/sleep need/balance), Day Strain (strain/calories/HR), Workouts (sport/duration/strain/HR/zones/distance), Weekly summaries.
## Setup
### 1. Create WHOOP Developer App
1. Go to https://developer-dashboard.whoop.com/
2. Create Application → Redirect URI: `http://localhost:9527/callback` → select all `read:*` + `offline` scopes
3. Note Client ID and Client Secret
### 2. Store Credentials
**Env vars:**
```bash
export WHOOP_CLIENT_ID="your-id"
export WHOOP_CLIENT_SECRET="your-secret"
```
**Or 1Password:** Create Login item named `whoop` (username=Client ID, password=Client Secret).
### 3. Authorize (one-time)
**Local (browser on same machine):**
```bash
python3 scripts/auth.py
```
**Remote server (headless):**
```bash
python3 scripts/auth.py --print-url
# User opens URL in browser, authorizes, copies callback URL back
python3 scripts/auth.py --callback-url "http://localhost:9527/callback?code=xxx&state=yyy"
```
Tokens auto-refresh via `offline` scope. Authorize once, runs forever.
## Usage
```bash
python3 scripts/sync.py # Sync today
python3 scripts/sync.py --days 7 # Last 7 days
python3 scripts/sync.py --weekly # Weekly summary
python3 scripts/sync.py --date 2026-03-07 # Specific date
```
Output: `~/.openclaw/workspace/health/whoop-YYYY-MM-DD.md`
## Cron (daily auto-sync)
```bash
openclaw cron add \
--name whoop-daily \
--schedule "0 10 * * *" \
--timezone Asia/Shanghai \
--task "Run: python3 ~/.openclaw/workspace/skills/whoop/scripts/sync.py --days 2. Then read the generated markdown files and send me the latest day's report."
```
## Troubleshooting
| Problem | Fix |
|---------|-----|
| `No tokens found` | Run `auth.py` first |
| `Token refresh failed (403)` | Re-run `auth.py` to re-authorize |
| `error code: 1010` | Cloudflare block — uses curl to avoid. Check network |
| `No data for date` | WHOOP finalizes sleep after waking; try later |
## Agent Notes
- When user asks to re-authorize: run `auth.py --print-url`, send URL, wait for callback URL
- After authorization: verify with `sync.py --days 1`
- Token exchange uses `curl` to bypass Cloudflare blocking Python urllib
FILE:README.md
# WHOOP → AI Health Reports 🏋️
[中文文档 / Chinese](README_CN.md)
Turn your WHOOP data into daily AI health reports. 5 steps, 10 minutes.
## How It Works
```
WHOOP band → WHOOP API → Markdown files → AI reads & reports
```
Your AI agent syncs recovery, sleep, strain, and workout data daily, then sends you a plain-language health briefing.
## Quick Start
### Step 1: Install the skill
```bash
# OpenClaw users
clawhub install whoop
# Or manual
git clone https://github.com/aikong-cmd/whoop-openclaw.git
cp -r whoop-openclaw ~/.openclaw/workspace/skills/whoop
```
### Step 2: Create a WHOOP Developer App
Go to **https://developer-dashboard.whoop.com** → Create Application:
| Field | Value |
|-------|-------|
| Name | Anything (e.g. "AI Health Sync") |
| Redirect URI | `http://localhost:9527/callback` |
| Scopes | ✅ All `read:*` + `offline` |
Save your **Client ID** and **Client Secret**.
### Step 3: Set credentials
```bash
export WHOOP_CLIENT_ID="your-client-id"
export WHOOP_CLIENT_SECRET="your-client-secret"
```
### Step 4: Authorize (one-time)
**If you have a browser on the same machine:**
```bash
cd ~/.openclaw/workspace/skills/whoop
python3 scripts/auth.py
# Opens browser → log in → authorize → done ✅
```
**If running on a remote server (no browser):**
```bash
python3 scripts/auth.py --print-url
# 1. Open the printed URL in your local browser
# 2. Log in to WHOOP and authorize
# 3. Browser redirects to a page that won't load (normal!)
# 4. Copy the FULL URL from the address bar, then:
python3 scripts/auth.py --callback-url "paste-the-full-url-here"
```
You'll see `✅ Tokens saved`. This only needs to be done once — tokens auto-refresh forever.
### Step 5: Sync your data
```bash
python3 scripts/sync.py # Today
python3 scripts/sync.py --days 7 # Last 7 days
python3 scripts/sync.py --weekly # Weekly summary
```
Output goes to `~/.openclaw/workspace/health/whoop-YYYY-MM-DD.md`
**That's it.** Your AI agent can now read these files and answer health questions.
---
## Optional: Daily Auto-Reports
Set up a cron job so your AI sends you a health briefing every morning:
```bash
openclaw cron add \
--name whoop-daily \
--schedule "30 10 * * *" \
--timezone Asia/Shanghai \
--task "Run: python3 ~/.openclaw/workspace/skills/whoop/scripts/sync.py --days 2. Read the latest health file and send me a report with insights."
```
> ⏰ 10:30 AM recommended — WHOOP finalizes sleep data after you wake up.
## Sample Output
```markdown
# WHOOP — 2026-03-09
## Recovery 🟢
- Recovery Score: 66%
- HRV: 41.4 ms | Resting HR: 62 bpm
- SpO2: 96.3% | Skin Temp: 33.7°C
## Sleep 🟡
- Performance: 61% | In Bed: 5h47m
- Deep 1h25m | REM 1h38m | Light 2h08m | Awake 35m
- Sleep Need: 9h41m → Deficit: 4h29m
## Strain
- Day Strain: 0.1 / 21.0 | Calories: 534 kcal
## Workouts
- Walking · 16m · Strain 4.9 · Avg HR 114 bpm
```
## Troubleshooting
| Problem | Fix |
|---------|-----|
| `No tokens found` | Run Step 4 (auth.py) |
| `Token refresh failed` | Re-run auth.py |
| `No data for date` | WHOOP needs time after waking; try later |
| Port 9527 in use | `kill $(lsof -ti:9527)` then retry |
## Requirements
- Python 3.10+ and curl (pre-installed on most systems)
- WHOOP membership with an active band
## License
MIT
FILE:README_CN.md
# WHOOP → AI 健康日报 🏋️
[English README](README.md)
把 WHOOP 手环数据变成每日 AI 健康报告。5 步搞定,10 分钟。
## 工作原理
```
WHOOP 手环 → WHOOP API → Markdown 文件 → AI 读取并生成报告
```
AI 助手每天自动同步你的恢复、睡眠、负荷和运动数据,然后发给你一份通俗易懂的健康简报。
## 快速开始
### 第 1 步:安装
```bash
# OpenClaw 用户(一键)
clawhub install whoop
# 或手动安装
git clone https://github.com/aikong-cmd/whoop-openclaw.git
cp -r whoop-openclaw ~/.openclaw/workspace/skills/whoop
```
### 第 2 步:创建 WHOOP 开发者应用
打开 **https://developer-dashboard.whoop.com** → Create Application:
| 字段 | 填什么 |
|------|--------|
| Name | 随便填,比如 "AI Health Sync" |
| Redirect URI | `http://localhost:9527/callback` |
| Scopes | ✅ 勾选所有 `read:*` + `offline` |
保存你的 **Client ID** 和 **Client Secret**。
### 第 3 步:配置凭证
```bash
export WHOOP_CLIENT_ID="你的-client-id"
export WHOOP_CLIENT_SECRET="你的-client-secret"
```
### 第 4 步:授权(只需一次)
**本地电脑(有浏览器):**
```bash
cd ~/.openclaw/workspace/skills/whoop
python3 scripts/auth.py
# 自动打开浏览器 → 登录 WHOOP → 点授权 → 搞定 ✅
```
**远程服务器(没浏览器):**
```bash
python3 scripts/auth.py --print-url
# 1. 在你电脑浏览器打开这个链接
# 2. 登录 WHOOP,点授权
# 3. 浏览器会跳到一个打不开的页面(正常!)
# 4. 复制地址栏完整 URL,然后:
python3 scripts/auth.py --callback-url "粘贴完整URL"
```
看到 `✅ Tokens saved` 就成功了。这一步只需做一次,Token 永久自动续期。
### 第 5 步:同步数据
```bash
python3 scripts/sync.py # 同步今天
python3 scripts/sync.py --days 7 # 最近 7 天
python3 scripts/sync.py --weekly # 周报
```
数据保存在 `~/.openclaw/workspace/health/whoop-YYYY-MM-DD.md`
**搞定。** AI 助手现在可以读取这些文件,回答你的健康问题。
---
## 可选:每日自动推送
设置定时任务,让 AI 每天早上给你发健康简报:
```bash
openclaw cron add \
--name whoop-daily \
--schedule "30 10 * * *" \
--timezone Asia/Shanghai \
--task "Run: python3 ~/.openclaw/workspace/skills/whoop/scripts/sync.py --days 2. Read the latest health file and send me a report with insights."
```
> ⏰ 建议 10:30 — WHOOP 的睡眠数据要等起床后才能最终确认。
## 输出示例
```markdown
# WHOOP — 2026-03-09
## 恢复 🟢
- 恢复分数: 66%
- HRV: 41.4 ms | 静息心率: 62 bpm
- 血氧: 96.3% | 皮肤温度: 33.7°C
## 睡眠 🟡
- 表现: 61% | 在床时间: 5h47m
- 深睡 1h25m | REM 1h38m | 浅睡 2h08m | 清醒 35m
- 睡眠需求: 9h41m → 欠债: 4h29m
## 日间负荷
- 负荷: 0.1 / 21.0 | 消耗: 534 kcal
## 运动
- 步行 · 16分钟 · 负荷 4.9 · 平均心率 114 bpm
```
## 常见问题
| 问题 | 解决 |
|------|------|
| `No tokens found` | 先做第 4 步(auth.py) |
| `Token refresh failed` | 重新跑 auth.py |
| `No data for date` | WHOOP 要等起床后才有数据,稍后再试 |
| 端口 9527 被占用 | `kill $(lsof -ti:9527)` 后重试 |
## 系统要求
- Python 3.10+ 和 curl(大多数系统自带)
- WHOOP 会员 + 在用的手环
## License
MIT
FILE:data/tokens.json
{
"access_token": "UmWZ5K242SO3E27Ue7cX9aCJOg2fJIwN6w0oJZxcqss.Iw9VcVGWsGdwVhXK3aE15U_RAzUELrXK_1wGzu4NXaw",
"expires_in": 3599,
"refresh_token": "fAmQSB5ALDrtwRByWDgWEdEhOkDY8b3HX6zUDsS8LWc.TyFihzQ8R-47pJ3pIVuLx8dTciwpLx2GsOiDZsD11tA",
"scope": "read:recovery read:cycles read:workout read:sleep read:profile read:body_measurement offline",
"token_type": "bearer"
}
FILE:scripts/auth.py
#!/usr/bin/env python3
"""
WHOOP OAuth 2.0 Authorization Flow.
Two modes:
1. Local — browser can reach localhost:9527, fully automatic
2. Remote — server can't receive browser callback, manually provide the code
Usage:
# Local (browser on same machine):
python3 auth.py
# Remote (OpenClaw on server, browser on another machine):
python3 auth.py --print-url # Step 1: get the auth URL
python3 auth.py --code <auth_code> # Step 2: exchange code for tokens
# Or provide the full callback URL:
python3 auth.py --callback-url "http://localhost:9527/callback?code=xxx&state=yyy"
"""
import argparse
import http.server
import json
import os
import secrets
import sys
import urllib.parse
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = BASE_DIR / "data"
TOKEN_FILE = DATA_DIR / "tokens.json"
STATE_FILE = DATA_DIR / ".auth_state" # persists state for remote flow
AUTH_URL = "https://api.prod.whoop.com/oauth/oauth2/auth"
TOKEN_URL = "https://api.prod.whoop.com/oauth/oauth2/token"
REDIRECT_URI = "http://localhost:9527/callback"
SCOPES = "read:recovery read:cycles read:workout read:sleep read:profile read:body_measurement offline"
def get_credentials():
"""Get client_id and client_secret from environment or 1Password."""
client_id = os.environ.get("WHOOP_CLIENT_ID")
client_secret = os.environ.get("WHOOP_CLIENT_SECRET")
if client_id and client_secret:
return client_id, client_secret
# Try 1Password
try:
op_token_path = Path.home() / ".openclaw" / ".op-token"
if op_token_path.exists():
os.environ["OP_SERVICE_ACCOUNT_TOKEN"] = op_token_path.read_text().strip()
import subprocess
result = subprocess.run(
["op", "item", "get", "whoop", "--vault", "Agent", "--format", "json"],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
item = json.loads(result.stdout)
for f in item.get("fields", []):
if f.get("purpose") == "USERNAME":
client_id = f.get("value", "")
elif f.get("purpose") == "PASSWORD":
client_secret = f.get("value", "")
if client_id and client_secret:
return client_id, client_secret
except Exception as e:
print(f"1Password read failed: {e}", file=sys.stderr)
print("Error: WHOOP_CLIENT_ID and WHOOP_CLIENT_SECRET required.", file=sys.stderr)
print("Set env vars or store in 1Password (vault: Agent, item: whoop).", file=sys.stderr)
sys.exit(1)
def exchange_code(code: str, client_id: str, client_secret: str) -> dict:
"""Exchange authorization code for tokens via curl (bypasses Cloudflare 1010)."""
import subprocess
data = urllib.parse.urlencode({
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
"client_id": client_id,
"client_secret": client_secret,
})
r = subprocess.run([
"curl", "-s", "-w", "\n%{http_code}",
"-X", "POST", TOKEN_URL,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-H", "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"-d", data,
], capture_output=True, text=True, timeout=15)
lines = r.stdout.strip().rsplit("\n", 1)
body = lines[0] if len(lines) > 1 else r.stdout
status = lines[-1] if len(lines) > 1 else "?"
if status != "200":
print(f"Token exchange failed: {status} {body[:300]}", file=sys.stderr)
sys.exit(1)
return json.loads(body)
def save_tokens(tokens: dict):
"""Save tokens to disk."""
DATA_DIR.mkdir(parents=True, exist_ok=True)
TOKEN_FILE.write_text(json.dumps(tokens, indent=2))
os.chmod(TOKEN_FILE, 0o600)
print(f"✅ Tokens saved to {TOKEN_FILE}")
print(f" Access token expires in {tokens.get('expires_in', '?')} seconds")
if "refresh_token" in tokens:
print(" Refresh token: ✅ (auto-renewal enabled)")
else:
print(" ⚠️ No refresh token — did you include 'offline' scope?")
def build_auth_url(client_id: str, state: str) -> str:
"""Build the OAuth authorization URL."""
params = urllib.parse.urlencode({
"client_id": client_id,
"redirect_uri": REDIRECT_URI,
"response_type": "code",
"scope": SCOPES,
"state": state,
})
return f"{AUTH_URL}?{params}"
def mode_local(client_id: str, client_secret: str):
"""Full local flow: start callback server, wait for browser redirect."""
state = secrets.token_urlsafe(8)[:8]
auth_url = build_auth_url(client_id, state)
print(f"\n🔗 Open this URL in your browser to authorize:\n\n{auth_url}\n")
print(f"Waiting for callback on http://localhost:9527 ... (timeout: 3 min)\n")
authorization_code = None
class CallbackHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
nonlocal authorization_code
parsed = urllib.parse.urlparse(self.path)
qs = urllib.parse.parse_qs(parsed.query)
if "code" in qs:
returned_state = qs.get("state", [""])[0]
if returned_state != state:
self.send_response(400)
self.end_headers()
self.wfile.write(b"<h1>State mismatch!</h1><p>Try again from the beginning.</p>")
return
authorization_code = qs["code"][0]
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(b"<h1>Authorization successful!</h1><p>You can close this tab.</p>")
else:
self.send_response(400)
self.end_headers()
self.wfile.write(b"No code received.")
def log_message(self, format, *args):
pass # Suppress log output
server = http.server.HTTPServer(("localhost", 9527), CallbackHandler)
server.timeout = 180 # 3 minutes
server.handle_request()
if not authorization_code:
print("Error: No authorization code received.", file=sys.stderr)
sys.exit(1)
print("✅ Got authorization code, exchanging for tokens...")
tokens = exchange_code(authorization_code, client_id, client_secret)
save_tokens(tokens)
def mode_print_url(client_id: str):
"""Remote step 1: print auth URL and save state for later."""
state = secrets.token_urlsafe(8)[:8]
auth_url = build_auth_url(client_id, state)
# Save state so --code can verify it later
DATA_DIR.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps({"state": state}))
print(f"\n🔗 Open this URL in your browser to authorize:\n")
print(auth_url)
print(f"\nAfter authorizing, the browser will redirect to localhost:9527 (which won't load).")
print(f"Copy the FULL URL from the browser address bar and run:\n")
print(f" python3 auth.py --callback-url \"<paste the full URL here>\"\n")
print(f"Or extract the 'code' parameter and run:\n")
print(f" python3 auth.py --code \"<the code value>\"\n")
def mode_exchange_code(code: str, client_id: str, client_secret: str):
"""Remote step 2: exchange a manually-provided code for tokens."""
print("Exchanging authorization code for tokens...")
tokens = exchange_code(code, client_id, client_secret)
save_tokens(tokens)
# Clean up state file
if STATE_FILE.exists():
STATE_FILE.unlink()
def mode_callback_url(url: str, client_id: str, client_secret: str):
"""Remote step 2 (alt): extract code from a pasted callback URL."""
parsed = urllib.parse.urlparse(url)
qs = urllib.parse.parse_qs(parsed.query)
code = qs.get("code", [None])[0]
if not code:
print("Error: No 'code' parameter found in the URL.", file=sys.stderr)
print(f"URL received: {url}", file=sys.stderr)
sys.exit(1)
# Optionally verify state
returned_state = qs.get("state", [None])[0]
if returned_state and STATE_FILE.exists():
saved = json.loads(STATE_FILE.read_text())
if saved.get("state") != returned_state:
print(f"⚠️ State mismatch (expected {saved.get('state')}, got {returned_state}).")
print(" Proceeding anyway — the code may still be valid.\n")
mode_exchange_code(code, client_id, client_secret)
def main():
parser = argparse.ArgumentParser(description="WHOOP OAuth 2.0 Authorization")
group = parser.add_mutually_exclusive_group()
group.add_argument("--print-url", action="store_true",
help="Print auth URL only (for remote/server use)")
group.add_argument("--code",
help="Exchange an authorization code directly (remote step 2)")
group.add_argument("--callback-url",
help="Extract code from the full callback URL (remote step 2)")
args = parser.parse_args()
client_id, client_secret = get_credentials()
if args.print_url:
mode_print_url(client_id)
elif args.code:
mode_exchange_code(args.code, client_id, client_secret)
elif args.callback_url:
mode_callback_url(args.callback_url, client_id, client_secret)
else:
mode_local(client_id, client_secret)
if __name__ == "__main__":
main()
FILE:scripts/sync.py
#!/usr/bin/env python3
"""
WHOOP Health Data Sync — Direct API, no third-party SDK.
Usage:
python3 sync.py # Sync today
python3 sync.py --date 2026-03-07 # Sync specific date
python3 sync.py --days 7 # Sync last 7 days
python3 sync.py --weekly # Generate weekly report
"""
import argparse
import json
import os
import sys
import urllib.parse
import urllib.request
from datetime import date, datetime, timedelta
from pathlib import Path
from urllib.error import HTTPError
BASE_DIR = Path(__file__).resolve().parent.parent
HEALTH_DIR = BASE_DIR.parent.parent / "health" # ~/.openclaw/workspace/health/
DATA_DIR = BASE_DIR / "data"
TOKEN_FILE = DATA_DIR / "tokens.json"
API_BASE = "https://api.prod.whoop.com/developer/v2"
TOKEN_URL = "https://api.prod.whoop.com/oauth/oauth2/token"
# ── Token Management ──────────────────────────────────────────────
def load_tokens() -> dict:
if not TOKEN_FILE.exists():
print("Error: No tokens found. Run auth.py first.", file=sys.stderr)
sys.exit(1)
return json.loads(TOKEN_FILE.read_text())
def save_tokens(tokens: dict):
DATA_DIR.mkdir(parents=True, exist_ok=True)
TOKEN_FILE.write_text(json.dumps(tokens, indent=2))
os.chmod(TOKEN_FILE, 0o600)
def get_credentials():
"""Get client_id and client_secret from 1Password."""
try:
op_token_path = Path.home() / ".openclaw" / ".op-token"
if op_token_path.exists():
os.environ["OP_SERVICE_ACCOUNT_TOKEN"] = op_token_path.read_text().strip()
import subprocess
result = subprocess.run(
["op", "item", "get", "whoop", "--vault", "Agent", "--format", "json"],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
item = json.loads(result.stdout)
cid, csec = "", ""
for f in item.get("fields", []):
if f.get("purpose") == "USERNAME":
cid = f.get("value", "")
elif f.get("purpose") == "PASSWORD":
csec = f.get("value", "")
if cid and csec:
return cid, csec
except Exception:
pass
return os.environ.get("WHOOP_CLIENT_ID", ""), os.environ.get("WHOOP_CLIENT_SECRET", "")
def refresh_access_token(tokens: dict) -> dict:
"""Refresh the access token using the refresh token."""
refresh_token = tokens.get("refresh_token")
if not refresh_token:
print("Error: No refresh token. Re-run auth.py.", file=sys.stderr)
sys.exit(1)
client_id, client_secret = get_credentials()
if not client_id:
print("Error: Cannot refresh — no client credentials.", file=sys.stderr)
sys.exit(1)
data = urllib.parse.urlencode({
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": client_id,
"client_secret": client_secret,
}).encode()
# Use curl for token exchange to avoid Cloudflare 1010 blocking urllib
import subprocess as _sp
curl_r = _sp.run([
"curl", "-s", "-w", "\n%{http_code}",
"-X", "POST", TOKEN_URL,
"-H", "Content-Type: application/x-www-form-urlencoded",
"-H", "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"-d", data.decode(),
], capture_output=True, text=True, timeout=15)
lines = curl_r.stdout.strip().rsplit("\n", 1)
body = lines[0] if len(lines) > 1 else curl_r.stdout
status = lines[-1] if len(lines) > 1 else "?"
if status != "200":
print(f"Token refresh failed: {status} {body[:300]}", file=sys.stderr)
sys.exit(1)
new_tokens = json.loads(body)
# Preserve refresh token if not returned
if "refresh_token" not in new_tokens and refresh_token:
new_tokens["refresh_token"] = refresh_token
save_tokens(new_tokens)
return new_tokens
def get_access_token() -> str:
"""Get a valid access token, refreshing if needed."""
tokens = load_tokens()
# Try using current token first
# If it fails with 401, refresh
return tokens.get("access_token", ""), tokens
# ── API Calls ─────────────────────────────────────────────────────
def api_get(endpoint: str, token: str, params: dict | None = None) -> dict | list:
"""GET from WHOOP API. Returns parsed JSON."""
if params:
query = urllib.parse.urlencode(params)
url = f"{API_BASE}/{endpoint}?{query}"
else:
url = f"{API_BASE}/{endpoint}"
req = urllib.request.Request(url, headers={
"Authorization": f"Bearer {token}",
"User-Agent": "WHOOP-Sync/1.0",
})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
except HTTPError as e:
if e.code == 401:
return {"_auth_error": True}
print(f" API error {endpoint}: {e.code}", file=sys.stderr)
return {}
except Exception as e:
print(f" API error {endpoint}: {e}", file=sys.stderr)
return {}
def api_get_with_refresh(endpoint: str, tokens: dict, params: dict | None = None):
"""API GET with auto token refresh on 401."""
token = tokens.get("access_token", "")
result = api_get(endpoint, token, params)
if isinstance(result, dict) and result.get("_auth_error"):
print(" Token expired, refreshing...", file=sys.stderr)
tokens = refresh_access_token(tokens)
token = tokens.get("access_token", "")
result = api_get(endpoint, token, params)
return result, tokens
def get_collection(endpoint: str, tokens: dict, start: str, end: str) -> tuple[list, dict]:
"""Get paginated collection data."""
params = {"start": start, "end": end, "limit": "25"}
all_records = []
while True:
data, tokens = api_get_with_refresh(endpoint, tokens, params)
if not isinstance(data, dict):
break
records = data.get("records", [])
all_records.extend(records)
next_token = data.get("next_token")
if not next_token or not records:
break
params["nextToken"] = next_token
return all_records, tokens
# ── Formatting Helpers ────────────────────────────────────────────
def fmt_dur(ms: int | float | None) -> str:
if not ms:
return "N/A"
total_sec = int(ms / 1000)
h, rem = divmod(total_sec, 3600)
m, s = divmod(rem, 60)
if h > 0:
return f"{h}h{m:02d}m"
return f"{m}m{s:02d}s"
def fmt_pct(val: float | None) -> str:
if val is None:
return "N/A"
return f"{val:.0f}%"
def score_emoji(score: float | None, thresholds=(33, 66)) -> str:
if score is None:
return "❓"
if score >= thresholds[1]:
return "🟢"
if score >= thresholds[0]:
return "🟡"
return "🔴"
# ── Sync Logic ────────────────────────────────────────────────────
def sync_day(tokens: dict, target_date: date) -> tuple[str | None, dict]:
"""Sync one day of WHOOP data. Returns (markdown_content, updated_tokens)."""
ds = target_date.isoformat()
# WHOOP uses ISO datetime for start/end
start = f"{ds}T00:00:00.000Z"
end = f"{(target_date + timedelta(days=1)).isoformat()}T00:00:00.000Z"
print(f" Syncing {ds}...")
lines = [f"# WHOOP — {ds}\n"]
# ── Recovery ──
recoveries, tokens = get_collection("recovery", tokens, start, end)
if recoveries:
r = recoveries[-1] # Latest
score = r.get("score", {})
rec_score = score.get("recovery_score")
hrv = score.get("hrv_rmssd_milli")
rhr = score.get("resting_heart_rate")
spo2 = score.get("spo2_percentage")
skin_temp = score.get("skin_temp_celsius")
lines.append(f"## Recovery {score_emoji(rec_score)}")
lines.append(f"- **Recovery Score**: {fmt_pct(rec_score)}")
lines.append(f"- **HRV (RMSSD)**: {hrv:.1f} ms" if hrv else "- **HRV**: N/A")
lines.append(f"- **Resting HR**: {rhr} bpm" if rhr else "- **Resting HR**: N/A")
if spo2:
lines.append(f"- **SpO2**: {spo2:.1f}%")
if skin_temp:
lines.append(f"- **Skin Temp**: {skin_temp:.1f}°C")
lines.append("")
else:
lines.append("## Recovery\nNo data.\n")
# ── Sleep ──
sleeps, tokens = get_collection("activity/sleep", tokens, start, end)
if sleeps:
s = sleeps[-1]
score = s.get("score", {})
stage = score.get("stage_summary", {})
lines.append("## Sleep")
perf = score.get("sleep_performance_percentage")
if perf is not None:
lines.append(f"- **Performance**: {score_emoji(perf)} {fmt_pct(perf)}")
eff = score.get("sleep_efficiency_percentage")
if eff is not None:
lines.append(f"- **Efficiency**: {fmt_pct(eff)}")
consistency = score.get("sleep_consistency_percentage")
if consistency is not None:
lines.append(f"- **Consistency**: {fmt_pct(consistency)}")
total = stage.get("total_in_bed_time_milli")
if total:
lines.append(f"- **Total in Bed**: {fmt_dur(total)}")
# Sleep stages
light = stage.get("total_light_sleep_time_milli")
slow = stage.get("total_slow_wave_sleep_time_milli")
rem = stage.get("total_rem_sleep_time_milli")
awake = stage.get("total_awake_time_milli")
if any(x is not None for x in [light, slow, rem, awake]):
lines.append("- **Stages**:")
if light:
lines.append(f" - Light: {fmt_dur(light)}")
if slow:
lines.append(f" - Deep (SWS): {fmt_dur(slow)}")
if rem:
lines.append(f" - REM: {fmt_dur(rem)}")
if awake:
lines.append(f" - Awake: {fmt_dur(awake)}")
resp_rate = score.get("respiratory_rate")
if resp_rate:
lines.append(f"- **Respiratory Rate**: {resp_rate:.1f} breaths/min")
# Sleep need / debt
need = score.get("sleep_needed", {})
if need:
baseline = need.get("baseline_milli")
debt = need.get("need_from_sleep_debt_milli")
strain_need = need.get("need_from_recent_strain_milli")
nap_credit = need.get("need_from_recent_nap_milli")
if baseline:
total_need = baseline + (debt or 0) + (strain_need or 0) - (nap_credit or 0)
lines.append(f"- **Sleep Need**: {fmt_dur(total_need)}")
lines.append(f" - Baseline: {fmt_dur(baseline)}")
if debt:
lines.append(f" - Sleep Debt: +{fmt_dur(debt)}")
if strain_need:
lines.append(f" - Strain Need: +{fmt_dur(strain_need)}")
if nap_credit:
lines.append(f" - Nap Credit: -{fmt_dur(nap_credit)}")
# Actual sleep vs need
actual_sleep = (stage.get("total_in_bed_time_milli", 0)
- stage.get("total_awake_time_milli", 0))
if actual_sleep and total_need:
diff_ms = actual_sleep - total_need
sign = "+" if diff_ms >= 0 else ""
lines.append(f" - **Balance**: {sign}{fmt_dur(abs(diff_ms))} {'surplus' if diff_ms >= 0 else 'deficit'}")
lines.append("")
else:
lines.append("## Sleep\nNo data.\n")
# ── Cycles (Strain) ──
cycles, tokens = get_collection("cycle", tokens, start, end)
if cycles:
c = cycles[-1]
score = c.get("score", {})
strain = score.get("strain")
kj = score.get("kilojoule")
avg_hr = score.get("average_heart_rate")
max_hr = score.get("max_heart_rate")
lines.append("## Day Strain")
if strain is not None:
lines.append(f"- **Strain**: {strain:.1f} / 21.0")
if kj is not None:
lines.append(f"- **Calories**: {kj:.0f} kJ ({kj * 0.239:.0f} kcal)")
if avg_hr:
lines.append(f"- **Avg HR**: {avg_hr} bpm")
if max_hr:
lines.append(f"- **Max HR**: {max_hr} bpm")
lines.append("")
else:
lines.append("## Day Strain\nNo data.\n")
# ── Workouts ──
workouts, tokens = get_collection("activity/workout", tokens, start, end)
if workouts:
lines.append("## Workouts")
for i, w in enumerate(workouts, 1):
score = w.get("score", {})
sport_id = w.get("sport_id", "?")
strain = score.get("strain")
avg_hr = score.get("average_heart_rate")
max_hr = score.get("max_heart_rate")
kj = score.get("kilojoule")
dur = score.get("duration_milli") or w.get("end") and w.get("start")
sport_name = w.get("sport_name", "").replace("-", " ").title() or f"Sport {sport_id}"
# Duration from start/end
duration_str = ""
start_t = w.get("start")
end_t = w.get("end")
if start_t and end_t:
try:
from datetime import datetime as _dt
s_t = _dt.fromisoformat(start_t.replace("Z", "+00:00"))
e_t = _dt.fromisoformat(end_t.replace("Z", "+00:00"))
dur_ms = (e_t - s_t).total_seconds() * 1000
duration_str = f" · {fmt_dur(dur_ms)}"
except Exception:
pass
lines.append(f"### {sport_name}{duration_str}")
if strain is not None:
lines.append(f"- **Strain**: {strain:.1f}")
if kj is not None:
lines.append(f"- **Calories**: {kj:.0f} kJ ({kj * 0.239:.0f} kcal)")
if avg_hr:
lines.append(f"- **Avg HR**: {avg_hr} bpm")
if max_hr:
lines.append(f"- **Max HR**: {max_hr} bpm")
dist = score.get("distance_meter")
if dist:
if dist >= 1000:
lines.append(f"- **Distance**: {dist/1000:.2f} km")
else:
lines.append(f"- **Distance**: {dist:.0f} m")
alt_gain = score.get("altitude_gain_meter")
alt_change = score.get("altitude_change_meter")
if alt_gain:
alt_str = f"- **Elevation**: ↑{alt_gain:.0f}m"
if alt_change is not None:
alt_str += f" (net {alt_change:+.0f}m)"
lines.append(alt_str)
# HR zones
zones = score.get("zone_durations", {}) or score.get("zone_duration", {})
if zones:
lines.append("- **HR Zones**:")
for z_name in ["zone_zero_milli", "zone_one_milli", "zone_two_milli",
"zone_three_milli", "zone_four_milli", "zone_five_milli"]:
z_val = zones.get(z_name)
if z_val:
z_label = z_name.replace("_milli", "").replace("zone_", "Zone ").title()
lines.append(f" - {z_label}: {fmt_dur(z_val)}")
lines.append("")
else:
lines.append("## Workouts\nNo workouts.\n")
content = "\n".join(lines)
# Only return if we got any actual data
has_data = any(x for x in [recoveries, sleeps, cycles, workouts])
return (content if has_data else None), tokens
def generate_weekly(tokens: dict, end_date: date) -> tuple[str, dict]:
"""Generate a weekly summary report."""
start_date = end_date - timedelta(days=6)
start = f"{start_date.isoformat()}T00:00:00.000Z"
end = f"{(end_date + timedelta(days=1)).isoformat()}T00:00:00.000Z"
lines = [f"# WHOOP Weekly Report — {start_date} → {end_date}\n"]
# Get all data for the week
recoveries, tokens = get_collection("recovery", tokens, start, end)
sleeps, tokens = get_collection("activity/sleep", tokens, start, end)
cycles, tokens = get_collection("cycle", tokens, start, end)
workouts, tokens = get_collection("activity/workout", tokens, start, end)
# Recovery summary
lines.append("## Recovery")
if recoveries:
scores = [r["score"]["recovery_score"] for r in recoveries if r.get("score", {}).get("recovery_score") is not None]
hrvs = [r["score"]["hrv_rmssd_milli"] for r in recoveries if r.get("score", {}).get("hrv_rmssd_milli") is not None]
rhrs = [r["score"]["resting_heart_rate"] for r in recoveries if r.get("score", {}).get("resting_heart_rate") is not None]
if scores:
lines.append(f"- **Avg Recovery**: {sum(scores)/len(scores):.0f}% (range: {min(scores):.0f}%–{max(scores):.0f}%)")
if hrvs:
lines.append(f"- **Avg HRV**: {sum(hrvs)/len(hrvs):.1f} ms (range: {min(hrvs):.1f}–{max(hrvs):.1f})")
if rhrs:
lines.append(f"- **Avg RHR**: {sum(rhrs)/len(rhrs):.0f} bpm")
lines.append(f"- **Days with data**: {len(recoveries)}/7")
else:
lines.append("No recovery data.")
lines.append("")
# Sleep summary
lines.append("## Sleep")
if sleeps:
perfs = [s["score"]["sleep_performance_percentage"] for s in sleeps
if s.get("score", {}).get("sleep_performance_percentage") is not None]
total_beds = [s["score"]["stage_summary"]["total_in_bed_time_milli"] for s in sleeps
if s.get("score", {}).get("stage_summary", {}).get("total_in_bed_time_milli")]
if perfs:
lines.append(f"- **Avg Sleep Performance**: {sum(perfs)/len(perfs):.0f}%")
if total_beds:
avg_bed_h = (sum(total_beds) / len(total_beds)) / 3600000
lines.append(f"- **Avg Time in Bed**: {avg_bed_h:.1f}h")
lines.append(f"- **Nights with data**: {len(sleeps)}/7")
else:
lines.append("No sleep data.")
lines.append("")
# Strain summary
lines.append("## Strain")
if cycles:
strains = [c["score"]["strain"] for c in cycles if c.get("score", {}).get("strain") is not None]
cals = [c["score"]["kilojoule"] for c in cycles if c.get("score", {}).get("kilojoule") is not None]
if strains:
lines.append(f"- **Avg Day Strain**: {sum(strains)/len(strains):.1f}")
lines.append(f"- **Max Strain**: {max(strains):.1f}")
if cals:
lines.append(f"- **Avg Daily Calories**: {sum(cals)/len(cals)*0.239:.0f} kcal")
else:
lines.append("No cycle data.")
lines.append("")
# Workouts
lines.append("## Workouts")
if workouts:
lines.append(f"- **Total workouts**: {len(workouts)}")
total_strain = sum(w["score"]["strain"] for w in workouts if w.get("score", {}).get("strain") is not None)
lines.append(f"- **Total workout strain**: {total_strain:.1f}")
else:
lines.append("No workouts.")
lines.append("")
return "\n".join(lines), tokens
# ── Main ──────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Sync WHOOP health data")
parser.add_argument("--date", help="Specific date (YYYY-MM-DD)")
parser.add_argument("--days", type=int, help="Sync last N days")
parser.add_argument("--weekly", action="store_true", help="Generate weekly report")
parser.add_argument("--output-dir", help="Output directory (default: health/)")
args = parser.parse_args()
tokens = load_tokens()
out_dir = Path(args.output_dir) if args.output_dir else HEALTH_DIR
out_dir.mkdir(parents=True, exist_ok=True)
if args.weekly:
end = date.fromisoformat(args.date) if args.date else date.today()
content, tokens = generate_weekly(tokens, end)
out_file = out_dir / f"whoop-weekly-{end.isoformat()}.md"
out_file.write_text(content)
save_tokens(tokens)
print(f"Weekly report: {out_file}")
print(content)
return
if args.days:
days = [date.today() - timedelta(days=i) for i in range(args.days)]
elif args.date:
days = [date.fromisoformat(args.date)]
else:
days = [date.today()]
print(f"Syncing {len(days)} day(s)...")
for day in sorted(days):
content, tokens = sync_day(tokens, day)
if content:
out_file = out_dir / f"whoop-{day.isoformat()}.md"
out_file.write_text(content)
print(f" ✅ {out_file}")
else:
print(f" ⚠️ No data for {day}")
save_tokens(tokens)
print("Done.")
if __name__ == "__main__":
main()