feat(whoop): add Whoop fitness plugin for data access

- Introduced a new Whoop fitness plugin that allows querying of recovery scores, sleep analysis, cycle data, and workout tracking.
- Implemented OAuth 2.0 authentication flow for secure access to Whoop API.
- Added comprehensive documentation and example configurations for plugin setup.
- Included unit tests for API client methods to ensure reliability and correctness.
- Updated pnpm-lock.yaml to reflect new optional dependencies for the plugin.
This commit is contained in:
Jack Fiengo 2026-01-26 16:03:37 -05:00
parent 20f6a5546f
commit e3bb3790b7
9 changed files with 1237 additions and 7 deletions

View File

@ -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

View File

@ -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"
}
}
}

100
extensions/whoop/index.ts Normal file
View File

@ -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 });
}

View File

@ -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": {}
}

View File

@ -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<void>;
log: (msg: string) => void;
note: (message: string, title?: string) => Promise<void>;
prompt: (message: string) => Promise<string>;
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(
"<!doctype html><html><head><meta charset='utf-8'/></head>" +
"<body><h2>Whoop OAuth complete</h2>" +
"<p>You can close this window and return to Clawdbot.</p></body></html>",
);
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<WhoopOAuthCredentials> {
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<WhoopOAuthCredentials> {
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<WhoopOAuthCredentials> {
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;
}
}

View File

@ -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"
);
});
});
});

View File

@ -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<T> {
records: T[];
next_token?: string;
}
export class WhoopApiClient {
private config: WhoopApiConfig;
constructor(config: WhoopApiConfig) {
this.config = config;
}
private async request<T>(endpoint: string): Promise<T> {
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<T>;
}
/**
* Get recovery data
* @param limit Number of records to return (default: 10)
*/
async getRecovery(limit = 10): Promise<WhoopListResponse<WhoopRecovery>> {
const params = new URLSearchParams({ limit: limit.toString() });
return this.request<WhoopListResponse<WhoopRecovery>>(`/v2/recovery?${params}`);
}
/**
* Get current/latest recovery score
*/
async getCurrentRecovery(): Promise<WhoopRecovery | null> {
const response = await this.getRecovery(1);
return response.records[0] || null;
}
/**
* Get recovery by cycle ID
*/
async getRecoveryById(cycleId: number): Promise<WhoopRecovery> {
return this.request<WhoopRecovery>(`/v2/cycle/${cycleId}/recovery`);
}
/**
* Get sleep data
* @param limit Number of records to return (default: 10)
*/
async getSleep(limit = 10): Promise<WhoopListResponse<WhoopSleep>> {
const params = new URLSearchParams({ limit: limit.toString() });
return this.request<WhoopListResponse<WhoopSleep>>(`/v2/activity/sleep?${params}`);
}
/**
* Get latest sleep session
*/
async getLatestSleep(): Promise<WhoopSleep | null> {
const response = await this.getSleep(1);
return response.records[0] || null;
}
/**
* Get sleep by ID (v2 uses UUID)
*/
async getSleepById(sleepId: string): Promise<WhoopSleep> {
return this.request<WhoopSleep>(`/v2/activity/sleep/${sleepId}`);
}
/**
* Get cycle data (strain)
* @param limit Number of records to return (default: 10)
*/
async getCycles(limit = 10): Promise<WhoopListResponse<WhoopCycle>> {
const params = new URLSearchParams({ limit: limit.toString() });
return this.request<WhoopListResponse<WhoopCycle>>(`/v2/cycle?${params}`);
}
/**
* Get latest cycle
*/
async getLatestCycle(): Promise<WhoopCycle | null> {
const response = await this.getCycles(1);
return response.records[0] || null;
}
/**
* Get cycle by ID
*/
async getCycleById(cycleId: number): Promise<WhoopCycle> {
return this.request<WhoopCycle>(`/v2/cycle/${cycleId}`);
}
/**
* Get workout data
* @param limit Number of records to return (default: 10)
*/
async getWorkouts(limit = 10): Promise<WhoopListResponse<WhoopWorkout>> {
const params = new URLSearchParams({ limit: limit.toString() });
return this.request<WhoopListResponse<WhoopWorkout>>(`/v2/activity/workout?${params}`);
}
/**
* Get latest workout
*/
async getLatestWorkout(): Promise<WhoopWorkout | null> {
const response = await this.getWorkouts(1);
return response.records[0] || null;
}
/**
* Get workout by ID (v2 uses UUID)
*/
async getWorkoutById(workoutId: string): Promise<WhoopWorkout> {
return this.request<WhoopWorkout>(`/v2/activity/workout/${workoutId}`);
}
}

View File

@ -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<string> {
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}`,
};
}
},
};
}

16
pnpm-lock.yaml generated
View File

@ -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: