diff --git a/extensions/whoop/README.md b/extensions/whoop/README.md new file mode 100644 index 000000000..fd10a756e --- /dev/null +++ b/extensions/whoop/README.md @@ -0,0 +1,65 @@ +# Whoop (plugin) + +Adds an **optional** agent tool `get_whoop_data` for querying Whoop fitness data including recovery scores, sleep analysis, strain/cycle tracking, and workout metrics. + +## Enable + +1) Configure the plugin with your Whoop OAuth credentials: + +```json +{ + "plugins": { + "entries": { + "whoop": { + "enabled": true, + "config": { + "clientId": "your-client-id", + "clientSecret": "your-client-secret" + } + } + } + } +} +``` + +2) Register redirect URI at [developer.whoop.com](https://developer.whoop.com/): +``` +http://localhost:8086/oauth2callback +``` + +3) Complete OAuth flow to authenticate with Whoop. + +4) Allowlist the tool (it is registered with `optional: true`): + +```json +{ + "agents": { + "list": [ + { + "id": "main", + "tools": { "allow": ["get_whoop_data"] } + } + ] + } +} +``` + +## Data Types + +The tool provides access to: +- **Recovery**: HRV, resting heart rate, SpO2, skin temperature +- **Sleep**: Sleep stages, performance, efficiency, respiratory rate +- **Cycles**: Daily strain, average/max heart rate +- **Workouts**: Activity strain, heart rate zones, distance, calories + +## Query Types + +- `latest` - Get most recent single record +- `recent` - Get last N records (default: 7, max: 25) +- `by_id` - Get specific record by ID + +## Notes + +- Uses Whoop API v2 endpoints +- Automatic token refresh when expired +- Tokens stored securely via Clawdbot's credential system diff --git a/extensions/whoop/clawdbot.plugin.json b/extensions/whoop/clawdbot.plugin.json new file mode 100644 index 000000000..5e600971f --- /dev/null +++ b/extensions/whoop/clawdbot.plugin.json @@ -0,0 +1,31 @@ +{ + "id": "whoop", + "name": "Whoop Fitness", + "description": "Query Whoop fitness data including recovery scores, sleep analysis, strain/cycle data, and workout tracking", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "clientId": { + "type": "string", + "description": "Whoop API OAuth Client ID" + }, + "clientSecret": { + "type": "string", + "description": "Whoop API OAuth Client Secret" + } + }, + "required": ["clientId", "clientSecret"] + }, + "uiHints": { + "clientId": { + "label": "Whoop Client ID", + "placeholder": "Your Client ID from developer.whoop.com" + }, + "clientSecret": { + "label": "Whoop Client Secret", + "sensitive": true, + "placeholder": "Your Client Secret from developer.whoop.com" + } + } +} diff --git a/extensions/whoop/index.ts b/extensions/whoop/index.ts new file mode 100644 index 000000000..5436838c7 --- /dev/null +++ b/extensions/whoop/index.ts @@ -0,0 +1,100 @@ +/** + * Whoop Fitness Plugin for Clawdbot + * Provides access to Whoop recovery, sleep, cycle, and workout data + */ + +import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { createWhoopTool } from "./src/whoop-tool.js"; +import { loginWhoopOAuth } from "./src/oauth.js"; + +const PROVIDER_ID = "whoop"; +const PROVIDER_LABEL = "Whoop Fitness"; + +export default function register(api: ClawdbotPluginApi) { + api.logger.info("Registering Whoop fitness plugin"); + + // Register OAuth provider + api.registerProvider({ + id: PROVIDER_ID, + label: PROVIDER_LABEL, + docsPath: "/plugins/whoop", + envVars: ["WHOOP_CLIENT_ID", "WHOOP_CLIENT_SECRET"], + auth: [ + { + id: "oauth", + label: "Whoop OAuth", + hint: "OAuth 2.0 with localhost callback", + kind: "oauth", + run: async (ctx) => { + const spin = ctx.prompter.progress("Starting Whoop OAuth…"); + + // Get client credentials from config + const config = api.config?.plugins?.entries?.whoop?.config as + | { clientId?: string; clientSecret?: string } + | undefined; + + const clientId = + config?.clientId || process.env.WHOOP_CLIENT_ID || process.env.CLAWDBOT_WHOOP_CLIENT_ID; + const clientSecret = + config?.clientSecret || + process.env.WHOOP_CLIENT_SECRET || + process.env.CLAWDBOT_WHOOP_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + spin.stop("Configuration missing"); + throw new Error( + "Whoop Client ID and Client Secret required. Add them to your config or set WHOOP_CLIENT_ID/WHOOP_CLIENT_SECRET environment variables.", + ); + } + + try { + const result = await loginWhoopOAuth({ + isRemote: ctx.isRemote, + openUrl: ctx.openUrl, + log: (msg) => ctx.runtime.log(msg), + note: ctx.prompter.note, + prompt: async (message) => String(await ctx.prompter.text({ message })), + progress: spin, + clientId, + clientSecret, + }); + + spin.stop("Whoop OAuth complete"); + + const profileId = `whoop:${result.userId ?? "default"}`; + + return { + profiles: [ + { + profileId, + credential: { + type: "oauth", + provider: PROVIDER_ID, + access: result.access, + refresh: result.refresh, + expires: result.expires, + userId: result.userId, + }, + }, + ], + notes: [ + "Your Whoop account is now connected!", + "The agent tool 'get_whoop_data' will be available once allowlisted.", + ], + }; + } catch (err) { + spin.stop("Whoop OAuth failed"); + await ctx.prompter.note( + "Check that your redirect URI (http://localhost:8086/oauth2callback) is registered in your Whoop app settings at developer.whoop.com", + "OAuth help", + ); + throw err; + } + }, + }, + ], + }); + + // Register tool + api.registerTool(createWhoopTool(api), { optional: true }); +} diff --git a/extensions/whoop/package.json b/extensions/whoop/package.json new file mode 100644 index 000000000..ee1345cf9 --- /dev/null +++ b/extensions/whoop/package.json @@ -0,0 +1,11 @@ +{ + "name": "@clawdbot/whoop", + "version": "2026.1.25", + "type": "module", + "description": "Clawdbot Whoop fitness plugin - access recovery, sleep, cycles, and workout data", + "clawdbot": { + "extensions": ["./index.ts"] + }, + "dependencies": {}, + "devDependencies": {} +} diff --git a/extensions/whoop/src/oauth.ts b/extensions/whoop/src/oauth.ts new file mode 100644 index 000000000..e436447a0 --- /dev/null +++ b/extensions/whoop/src/oauth.ts @@ -0,0 +1,320 @@ +/** + * Whoop OAuth 2.0 helper + */ + +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; + +const AUTH_URL = "https://api.prod.whoop.com/oauth/oauth2/auth"; +const TOKEN_URL = "https://api.prod.whoop.com/oauth/oauth2/token"; +const REDIRECT_URI = "http://localhost:8086/oauth2callback"; +const SCOPES = [ + "read:recovery", + "read:sleep", + "read:cycles", + "read:workout", + "offline", // Required for refresh tokens +]; + +export interface WhoopOAuthCredentials { + access: string; + refresh: string; + expires: number; + userId?: string; +} + +export interface WhoopOAuthContext { + isRemote: boolean; + openUrl: (url: string) => Promise; + log: (msg: string) => void; + note: (message: string, title?: string) => Promise; + prompt: (message: string) => Promise; + progress: { update: (msg: string) => void; stop: (msg?: string) => void }; + clientId: string; + clientSecret: string; +} + +function generateState(): string { + return randomBytes(16).toString("hex"); +} + +function buildAuthUrl(clientId: string, state: string): string { + const params = new URLSearchParams({ + client_id: clientId, + response_type: "code", + redirect_uri: REDIRECT_URI, + scope: SCOPES.join(" "), + state, + }); + return `${AUTH_URL}?${params.toString()}`; +} + +function parseCallbackInput( + input: string, + expectedState: string, +): { code: string; state: string } | { error: string } { + const trimmed = input.trim(); + if (!trimmed) return { error: "No input provided" }; + + try { + const url = new URL(trimmed); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state") ?? expectedState; + if (!code) return { error: "Missing 'code' parameter in URL" }; + if (!state) return { error: "Missing 'state' parameter. Paste the full URL." }; + return { code, state }; + } catch { + if (!expectedState) return { error: "Paste the full redirect URL, not just the code." }; + return { code: trimmed, state: expectedState }; + } +} + +async function waitForLocalCallback(params: { + expectedState: string; + timeoutMs: number; + onProgress?: (message: string) => void; +}): Promise<{ code: string; state: string }> { + const port = 8086; + const hostname = "localhost"; + const expectedPath = "/oauth2callback"; + + return new Promise<{ code: string; state: string }>((resolve, reject) => { + let timeout: NodeJS.Timeout | null = null; + const server = createServer((req, res) => { + try { + const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`); + if (requestUrl.pathname !== expectedPath) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain"); + res.end("Not found"); + return; + } + + const error = requestUrl.searchParams.get("error"); + const code = requestUrl.searchParams.get("code")?.trim(); + const state = requestUrl.searchParams.get("state")?.trim(); + + if (error) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain"); + res.end(`Authentication failed: ${error}`); + finish(new Error(`OAuth error: ${error}`)); + return; + } + + if (!code || !state) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain"); + res.end("Missing code or state"); + finish(new Error("Missing OAuth code or state")); + return; + } + + if (state !== params.expectedState) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain"); + res.end("Invalid state"); + finish(new Error("OAuth state mismatch")); + return; + } + + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end( + "" + + "

Whoop OAuth complete

" + + "

You can close this window and return to Clawdbot.

", + ); + + finish(undefined, { code, state }); + } catch (err) { + finish(err instanceof Error ? err : new Error("OAuth callback failed")); + } + }); + + const finish = (err?: Error, result?: { code: string; state: string }) => { + if (timeout) clearTimeout(timeout); + try { + server.close(); + } catch { + // ignore close errors + } + if (err) { + reject(err); + } else if (result) { + resolve(result); + } + }; + + server.once("error", (err) => { + finish(err instanceof Error ? err : new Error("OAuth callback server error")); + }); + + server.listen(port, hostname, () => { + params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}…`); + }); + + timeout = setTimeout(() => { + finish(new Error("OAuth callback timeout")); + }, params.timeoutMs); + }); +} + +async function exchangeCodeForTokens( + code: string, + clientId: string, + clientSecret: string, +): Promise { + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: REDIRECT_URI, + }); + + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Token exchange failed: ${errorText}`); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + user_id?: string; + }; + + if (!data.refresh_token) { + throw new Error("No refresh token received. Ensure the 'offline' scope is requested."); + } + + const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000; // 5min buffer + + return { + access: data.access_token, + refresh: data.refresh_token, + expires: expiresAt, + userId: data.user_id, + }; +} + +export async function refreshAccessToken( + refreshToken: string, + clientId: string, + clientSecret: string, +): Promise { + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + client_secret: clientSecret, + }); + + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Token refresh failed: ${errorText}`); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + user_id?: string; + }; + + const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000; + + return { + access: data.access_token, + refresh: data.refresh_token, + expires: expiresAt, + userId: data.user_id, + }; +} + +function shouldUseManualOAuthFlow(isRemote: boolean): boolean { + return isRemote; +} + +export async function loginWhoopOAuth(ctx: WhoopOAuthContext): Promise { + const needsManual = shouldUseManualOAuthFlow(ctx.isRemote); + + await ctx.note( + needsManual + ? [ + "You are running in a remote/VPS environment.", + "A URL will be shown for you to open in your LOCAL browser.", + "After signing in, copy the redirect URL and paste it back here.", + ].join("\n") + : [ + "Browser will open for Whoop authentication.", + "Sign in with your Whoop account.", + "The callback will be captured automatically on localhost:8086.", + ].join("\n"), + "Whoop OAuth", + ); + + const state = generateState(); + const authUrl = buildAuthUrl(ctx.clientId, state); + + if (needsManual) { + ctx.progress.update("OAuth URL ready"); + ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`); + ctx.progress.update("Waiting for you to paste the callback URL..."); + const callbackInput = await ctx.prompt("Paste the redirect URL here: "); + const parsed = parseCallbackInput(callbackInput, state); + if ("error" in parsed) throw new Error(parsed.error); + if (parsed.state !== state) { + throw new Error("OAuth state mismatch - please try again"); + } + ctx.progress.update("Exchanging authorization code for tokens..."); + return exchangeCodeForTokens(parsed.code, ctx.clientId, ctx.clientSecret); + } + + ctx.progress.update("Complete sign-in in browser..."); + try { + await ctx.openUrl(authUrl); + } catch { + ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`); + } + + try { + const { code } = await waitForLocalCallback({ + expectedState: state, + timeoutMs: 5 * 60 * 1000, + onProgress: (msg) => ctx.progress.update(msg), + }); + ctx.progress.update("Exchanging authorization code for tokens..."); + return await exchangeCodeForTokens(code, ctx.clientId, ctx.clientSecret); + } catch (err) { + if ( + err instanceof Error && + (err.message.includes("EADDRINUSE") || err.message.includes("port") || err.message.includes("listen")) + ) { + ctx.progress.update("Local callback server failed. Switching to manual mode..."); + ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`); + const callbackInput = await ctx.prompt("Paste the redirect URL here: "); + const parsed = parseCallbackInput(callbackInput, state); + if ("error" in parsed) throw new Error(parsed.error); + if (parsed.state !== state) { + throw new Error("OAuth state mismatch - please try again"); + } + ctx.progress.update("Exchanging authorization code for tokens..."); + return exchangeCodeForTokens(parsed.code, ctx.clientId, ctx.clientSecret); + } + throw err; + } +} diff --git a/extensions/whoop/src/whoop-api.test.ts b/extensions/whoop/src/whoop-api.test.ts new file mode 100644 index 000000000..adb436f63 --- /dev/null +++ b/extensions/whoop/src/whoop-api.test.ts @@ -0,0 +1,244 @@ +/** + * Tests for Whoop API Client + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { WhoopApiClient } from "./whoop-api.js"; + +describe("WhoopApiClient", () => { + let client: WhoopApiClient; + const mockAccessToken = "test-token-123"; + + beforeEach(() => { + client = new WhoopApiClient({ accessToken: mockAccessToken }); + vi.clearAllMocks(); + }); + + describe("Recovery", () => { + it("should fetch recovery data with limit", async () => { + const mockResponse = { + records: [ + { + cycle_id: 12345, + sleep_id: 67890, + user_id: 1, + created_at: "2026-01-26T08:00:00.000Z", + updated_at: "2026-01-26T08:00:00.000Z", + score_state: "SCORED", + score: { + user_calibrating: false, + recovery_score: 85, + resting_heart_rate: 52, + hrv_rmssd_milli: 65, + spo2_percentage: 98.5, + skin_temp_celsius: 33.2, + }, + }, + ], + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await client.getRecovery(1); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/recovery?limit=1"), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: `Bearer ${mockAccessToken}`, + }), + }) + ); + + expect(result.records).toHaveLength(1); + expect(result.records[0].score.recovery_score).toBe(85); + }); + + it("should fetch current recovery", async () => { + const mockResponse = { + records: [ + { + cycle_id: 12345, + sleep_id: 67890, + user_id: 1, + created_at: "2026-01-26T08:00:00.000Z", + updated_at: "2026-01-26T08:00:00.000Z", + score_state: "SCORED", + score: { + user_calibrating: false, + recovery_score: 75, + resting_heart_rate: 54, + hrv_rmssd_milli: 55, + spo2_percentage: 97.8, + skin_temp_celsius: 33.5, + }, + }, + ], + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await client.getCurrentRecovery(); + + expect(result).not.toBeNull(); + expect(result?.score.recovery_score).toBe(75); + }); + }); + + describe("Sleep", () => { + it("should fetch sleep data", async () => { + const mockResponse = { + records: [ + { + id: 67890, + user_id: 1, + created_at: "2026-01-26T07:00:00.000Z", + updated_at: "2026-01-26T07:00:00.000Z", + start: "2026-01-25T23:00:00.000Z", + end: "2026-01-26T07:00:00.000Z", + timezone_offset: "-08:00", + nap: false, + score_state: "SCORED", + score: { + stage_summary: { + total_in_bed_time_milli: 28800000, + total_awake_time_milli: 1800000, + total_no_data_time_milli: 0, + total_light_sleep_time_milli: 12600000, + total_slow_wave_sleep_time_milli: 7200000, + total_rem_sleep_time_milli: 7200000, + sleep_cycle_count: 5, + disturbance_count: 3, + }, + sleep_needed: { + baseline_milli: 28800000, + need_from_sleep_debt_milli: 0, + need_from_recent_strain_milli: 0, + need_from_recent_nap_milli: 0, + }, + respiratory_rate: 14.5, + sleep_performance_percentage: 95, + sleep_consistency_percentage: 88, + sleep_efficiency_percentage: 93, + }, + }, + ], + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await client.getSleep(1); + + expect(result.records).toHaveLength(1); + expect(result.records[0].score.sleep_performance_percentage).toBe(95); + }); + }); + + describe("Cycles", () => { + it("should fetch cycle data", async () => { + const mockResponse = { + records: [ + { + id: 12345, + user_id: 1, + created_at: "2026-01-26T08:00:00.000Z", + updated_at: "2026-01-26T08:00:00.000Z", + start: "2026-01-25T08:00:00.000Z", + end: "2026-01-26T08:00:00.000Z", + timezone_offset: "-08:00", + score_state: "SCORED", + score: { + strain: 12.5, + kilojoule: 8500, + average_heart_rate: 65, + max_heart_rate: 145, + }, + }, + ], + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await client.getCycles(1); + + expect(result.records).toHaveLength(1); + expect(result.records[0].score.strain).toBe(12.5); + }); + }); + + describe("Workouts", () => { + it("should fetch workout data", async () => { + const mockResponse = { + records: [ + { + id: 98765, + user_id: 1, + created_at: "2026-01-26T10:00:00.000Z", + updated_at: "2026-01-26T10:00:00.000Z", + start: "2026-01-26T09:00:00.000Z", + end: "2026-01-26T10:00:00.000Z", + timezone_offset: "-08:00", + sport_id: 1, + score_state: "SCORED", + score: { + strain: 8.5, + average_heart_rate: 135, + max_heart_rate: 165, + kilojoule: 2500, + percent_recorded: 100, + distance_meter: 5000, + altitude_gain_meter: 50, + altitude_change_meter: 50, + zone_duration: { + zone_zero_milli: 0, + zone_one_milli: 300000, + zone_two_milli: 900000, + zone_three_milli: 1200000, + zone_four_milli: 1200000, + zone_five_milli: 0, + }, + }, + }, + ], + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await client.getWorkouts(1); + + expect(result.records).toHaveLength(1); + expect(result.records[0].score.strain).toBe(8.5); + expect(result.records[0].score.distance_meter).toBe(5000); + }); + }); + + describe("Error handling", () => { + it("should handle API errors", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: async () => "Invalid access token", + }); + + await expect(client.getCurrentRecovery()).rejects.toThrow( + "Whoop API error (401): Invalid access token" + ); + }); + }); +}); diff --git a/extensions/whoop/src/whoop-api.ts b/extensions/whoop/src/whoop-api.ts new file mode 100644 index 000000000..e88e09956 --- /dev/null +++ b/extensions/whoop/src/whoop-api.ts @@ -0,0 +1,242 @@ +/** + * Whoop API Client + * Implements methods for recovery, sleep, cycles, and workout data + */ + +const WHOOP_API_BASE = 'https://api.prod.whoop.com/developer'; + +export interface WhoopApiConfig { + accessToken: string; +} + +export interface WhoopRecovery { + cycle_id: number; + sleep_id: string; // v2 uses UUID for sleep_id + user_id: number; + created_at: string; + updated_at: string; + score_state: string; + score: { + user_calibrating: boolean; + recovery_score: number; + resting_heart_rate: number; + hrv_rmssd_milli: number; + spo2_percentage: number; + skin_temp_celsius: number; + }; +} + +export interface WhoopSleep { + id: string; // v2 uses UUID + cycle_id: number; + v1_id?: number; + user_id: number; + created_at: string; + updated_at: string; + start: string; + end: string; + timezone_offset: string; + nap: boolean; + score_state: string; + score: { + stage_summary: { + total_in_bed_time_milli: number; + total_awake_time_milli: number; + total_no_data_time_milli: number; + total_light_sleep_time_milli: number; + total_slow_wave_sleep_time_milli: number; + total_rem_sleep_time_milli: number; + sleep_cycle_count: number; + disturbance_count: number; + }; + sleep_needed: { + baseline_milli: number; + need_from_sleep_debt_milli: number; + need_from_recent_strain_milli: number; + need_from_recent_nap_milli: number; + }; + respiratory_rate: number; + sleep_performance_percentage: number; + sleep_consistency_percentage: number; + sleep_efficiency_percentage: number; + }; +} + +export interface WhoopCycle { + id: number; + user_id: number; + created_at: string; + updated_at: string; + start: string; + end: string; + timezone_offset: string; + score_state: string; + score: { + strain: number; + kilojoule: number; + average_heart_rate: number; + max_heart_rate: number; + }; +} + +export interface WhoopWorkout { + id: string; // v2 uses UUID + v1_id?: number; + user_id: number; + created_at: string; + updated_at: string; + start: string; + end: string; + timezone_offset: string; + sport_id: number; + sport_name?: string; + score_state: string; + score: { + strain: number; + average_heart_rate: number; + max_heart_rate: number; + kilojoule: number; + percent_recorded: number; + distance_meter: number; + altitude_gain_meter: number; + altitude_change_meter: number; + zone_durations: { + zone_zero_milli: number; + zone_one_milli: number; + zone_two_milli: number; + zone_three_milli: number; + zone_four_milli: number; + zone_five_milli: number; + }; + }; +} + +export interface WhoopListResponse { + records: T[]; + next_token?: string; +} + +export class WhoopApiClient { + private config: WhoopApiConfig; + + constructor(config: WhoopApiConfig) { + this.config = config; + } + + private async request(endpoint: string): Promise { + const url = `${WHOOP_API_BASE}${endpoint}`; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.config.accessToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Whoop API error (${response.status}): ${errorText || response.statusText}` + ); + } + + return response.json() as Promise; + } + + /** + * Get recovery data + * @param limit Number of records to return (default: 10) + */ + async getRecovery(limit = 10): Promise> { + const params = new URLSearchParams({ limit: limit.toString() }); + return this.request>(`/v2/recovery?${params}`); + } + + /** + * Get current/latest recovery score + */ + async getCurrentRecovery(): Promise { + const response = await this.getRecovery(1); + return response.records[0] || null; + } + + /** + * Get recovery by cycle ID + */ + async getRecoveryById(cycleId: number): Promise { + return this.request(`/v2/cycle/${cycleId}/recovery`); + } + + /** + * Get sleep data + * @param limit Number of records to return (default: 10) + */ + async getSleep(limit = 10): Promise> { + const params = new URLSearchParams({ limit: limit.toString() }); + return this.request>(`/v2/activity/sleep?${params}`); + } + + /** + * Get latest sleep session + */ + async getLatestSleep(): Promise { + const response = await this.getSleep(1); + return response.records[0] || null; + } + + /** + * Get sleep by ID (v2 uses UUID) + */ + async getSleepById(sleepId: string): Promise { + return this.request(`/v2/activity/sleep/${sleepId}`); + } + + /** + * Get cycle data (strain) + * @param limit Number of records to return (default: 10) + */ + async getCycles(limit = 10): Promise> { + const params = new URLSearchParams({ limit: limit.toString() }); + return this.request>(`/v2/cycle?${params}`); + } + + /** + * Get latest cycle + */ + async getLatestCycle(): Promise { + const response = await this.getCycles(1); + return response.records[0] || null; + } + + /** + * Get cycle by ID + */ + async getCycleById(cycleId: number): Promise { + return this.request(`/v2/cycle/${cycleId}`); + } + + /** + * Get workout data + * @param limit Number of records to return (default: 10) + */ + async getWorkouts(limit = 10): Promise> { + const params = new URLSearchParams({ limit: limit.toString() }); + return this.request>(`/v2/activity/workout?${params}`); + } + + /** + * Get latest workout + */ + async getLatestWorkout(): Promise { + const response = await this.getWorkouts(1); + return response.records[0] || null; + } + + /** + * Get workout by ID (v2 uses UUID) + */ + async getWorkoutById(workoutId: string): Promise { + return this.request(`/v2/activity/workout/${workoutId}`); + } +} diff --git a/extensions/whoop/src/whoop-tool.ts b/extensions/whoop/src/whoop-tool.ts new file mode 100644 index 000000000..0e1b4fcf4 --- /dev/null +++ b/extensions/whoop/src/whoop-tool.ts @@ -0,0 +1,215 @@ +/** + * Whoop Tool for Clawdbot + * Provides agent access to Whoop fitness data + */ + +import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import type { Tool } from "@anthropic-ai/sdk/resources/messages.mjs"; +import { WhoopApiClient } from "./whoop-api.js"; +import { refreshAccessToken } from "./oauth.js"; + +interface WhoopPluginConfig { + clientId: string; + clientSecret: string; +} + +interface WhoopCredential { + type: "oauth"; + provider: string; + access: string; + refresh: string; + expires: number; + userId?: string; +} + +async function getAccessToken(api: ClawdbotPluginApi, config: WhoopPluginConfig): Promise { + const credentials = api.runtime?.credentials; + if (!credentials) { + throw new Error("Credentials runtime not available"); + } + + // Find Whoop credential + const creds = await credentials.list(); + const whoopCred = creds.find((c) => c.provider === "whoop" && c.type === "oauth") as + | WhoopCredential + | undefined; + + if (!whoopCred) { + throw new Error( + "No Whoop credentials found. Please run: clawdbot login whoop (or authenticate via the UI)", + ); + } + + // Check if token needs refresh + if (Date.now() >= whoopCred.expires) { + api.logger.info("Refreshing Whoop access token"); + const refreshed = await refreshAccessToken(whoopCred.refresh, config.clientId, config.clientSecret); + + // Update stored credentials + await credentials.update(whoopCred.profileId || "whoop:default", { + type: "oauth", + provider: "whoop", + access: refreshed.access, + refresh: refreshed.refresh, + expires: refreshed.expires, + userId: refreshed.userId, + }); + + return refreshed.access; + } + + return whoopCred.access; +} + +export function createWhoopTool(api: ClawdbotPluginApi): Tool { + return { + name: "get_whoop_data", + description: `Query Whoop fitness data including: +- Recovery scores (HRV, RHR, SpO2, skin temp) +- Sleep analysis (stages, quality, sleep debt) +- Cycle/strain data (daily strain, heart rate) +- Workout tracking (activities, heart rate zones, calories) + +Use this tool to answer questions about fitness metrics, sleep quality, recovery readiness, and workout performance.`, + input_schema: { + type: "object", + properties: { + data_type: { + type: "string", + enum: ["recovery", "sleep", "cycle", "workout"], + description: "The type of Whoop data to retrieve", + }, + query: { + type: "string", + enum: ["latest", "recent", "by_id"], + description: "How to query the data: 'latest' for most recent single record, 'recent' for last 7 days, 'by_id' for specific record", + }, + id: { + type: "number", + description: "The ID of the specific record (required when query is 'by_id')", + }, + limit: { + type: "number", + description: "Number of records to return for 'recent' query (default: 7, max: 25)", + }, + }, + required: ["data_type", "query"], + }, + async handler(input: unknown) { + const params = input as { + data_type: "recovery" | "sleep" | "cycle" | "workout"; + query: "latest" | "recent" | "by_id"; + id?: number; + limit?: number; + }; + + // Get config + const config = api.config?.plugins?.entries?.whoop?.config as WhoopPluginConfig | undefined; + + if (!config?.clientId || !config?.clientSecret) { + return { + error: + "Whoop plugin not configured. Please add your Client ID and Client Secret to the configuration.", + }; + } + + let accessToken: string; + try { + accessToken = await getAccessToken(api, config); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + return { error: errorMessage }; + } + + const client = new WhoopApiClient({ accessToken }); + + try { + // Handle query by ID + if (params.query === "by_id") { + if (!params.id) { + return { error: "ID is required when query is 'by_id'" }; + } + + let result; + switch (params.data_type) { + case "recovery": + result = await client.getRecoveryById(params.id); + break; + case "sleep": + result = await client.getSleepById(params.id); + break; + case "cycle": + result = await client.getCycleById(params.id); + break; + case "workout": + result = await client.getWorkoutById(params.id); + break; + } + + return { data: result }; + } + + // Handle latest query + if (params.query === "latest") { + let result; + switch (params.data_type) { + case "recovery": + result = await client.getCurrentRecovery(); + break; + case "sleep": + result = await client.getLatestSleep(); + break; + case "cycle": + result = await client.getLatestCycle(); + break; + case "workout": + result = await client.getLatestWorkout(); + break; + } + + if (!result) { + return { message: `No ${params.data_type} data found` }; + } + + return { data: result }; + } + + // Handle recent query + if (params.query === "recent") { + const limit = Math.min(params.limit || 7, 25); + let result; + + switch (params.data_type) { + case "recovery": + result = await client.getRecovery(limit); + break; + case "sleep": + result = await client.getSleep(limit); + break; + case "cycle": + result = await client.getCycles(limit); + break; + case "workout": + result = await client.getWorkouts(limit); + break; + } + + return { + data: result.records, + count: result.records.length, + next_token: result.next_token, + }; + } + + return { error: "Invalid query type" }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + api.logger.error("Whoop API error:", { error: errorMessage }); + + return { + error: `Failed to fetch Whoop data: ${errorMessage}`, + }; + } + }, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 223537e85..3a1d8611d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,13 +172,6 @@ importers: zod: specifier: ^4.3.6 version: 4.3.6 - optionalDependencies: - '@napi-rs/canvas': - specifier: ^0.1.88 - version: 0.1.88 - node-llama-cpp: - specifier: 3.15.0 - version: 3.15.0(typescript@5.9.3) devDependencies: '@grammyjs/types': specifier: ^3.23.0 @@ -261,6 +254,13 @@ importers: wireit: specifier: ^0.14.12 version: 0.14.12 + optionalDependencies: + '@napi-rs/canvas': + specifier: ^0.1.88 + version: 0.1.88 + node-llama-cpp: + specifier: 3.15.0 + version: 3.15.0(typescript@5.9.3) extensions/bluebubbles: {} @@ -457,6 +457,8 @@ importers: extensions/whatsapp: {} + extensions/whoop: {} + extensions/zalo: dependencies: clawdbot: