btwb-mcp
# btwb-mcp
[](LICENSE)
[](package.json)
[](https://modelcontextprotocol.io)
An unofficial [MCP](https://modelcontextprotocol.io) server that lets an LLM search BTWB's movement library, log CrossFit/weightlifting/gymnastics results, and pull your full training history for [Beyond the Whiteboard](https://beyondthewhiteboard.com) (BTWB) - straight from a chat, no copy-pasting between apps.
BTWB has no public API. This server calls the same internal JSON/form endpoints the BTWB web app itself uses, found by inspecting its network traffic. It authenticates with a copied browser session cookie rather than a real API key.
**This is unofficial and unsupported by BTWB.** It can break if they change their app, and your session cookie will periodically expire and need refreshing. Use it for personal automation only.
## Disclaimer
This project calls BTWB's internal, undocumented endpoints rather than a published API, using your own logged-in session cookie in place of an API key. It isn't affiliated with, endorsed by, or supported by BTWB, LLC. Using it may be subject to BTWB's own Terms of Service - review those and use this at your own discretion and risk. Provided as-is, with no warranty (see [LICENSE](LICENSE)).
## Tools
- **`search_movement(term)`** - search BTWB's movement library, returns `{id, name, modality, posting_trait}` matches.
- **`log_workout(movementId, movementName, reps, weight, weightUnit, performedDate, notes)`** - logs a single-movement result (e.g. a 1RM). **Always posts with Privacy: Only Me** - this is hardcoded in `src/btwb-client.js` and is not an exposed parameter, on purpose.
- **`log_rounds_workout(workoutId, workoutSlug, memberId, sections, totalTimeSeconds, performedDate, rxd, notes, trackEventId)`** - logs a multi-movement "rounds" result (e.g. a For Time WOD with several movements per round). Only "For Time" / total-time scoring is supported. **Always posts with Privacy: Only Me**, same as `log_workout`.
- **`log_weigh_in(weight, weighedInDate, hour, minute, percentBodyFat, notes)`** - logs a body-weight entry to BTWB's Weigh-Ins tracker (the dedicated weigh-in feature, not the "Weigh In" movement). Re-submits the height already stored on your BTWB profile (in Imperial mode BTWB reads it from the form's separate feet/inches fields, so those are sent too - without them every weigh-in fails with "Height must be greater than 0"). **No per-entry privacy** - BTWB's weigh-in form has no privacy field, so visibility follows your BTWB account settings, same as entering one by hand.
- **`get_weigh_ins(days)`** - reads your Weigh-Ins tracker: each entry's weight, BTWB's change vs. the previous entry, and timestamp, newest first. Optionally limited to the last N days.
- **`get_track_events(date, track)`** - lists the track events (class programming, personal tracks, ...) on one day of your whiteboard calendar, with each workout event's `trackEventId`, `workoutId` and `workoutSlug`. Events that already have a result come back as `logged` with their `sessionId`. Optional `track` name filter (e.g. `"class"`).
- **`log_sets_workout(workoutId, workoutSlug, sets, performedDate, rxd, notes, trackEventId)`** - logs a set-by-set lifting result (a `weightlifting/sets` or `single/emom` workout, e.g. "Bench Press : 3 @ 80%, ... 2 @ 85%") against that prescribed workout, one actual weight per prescribed set, optionally linked to a track event. The performed date can differ from the track event's day. **Always posted with Privacy: Only Me.**
- **`log_amrap_workout(workoutId, workoutSlug, rounds, extraReps, performedDate, rxd, loads, notes, trackEventId)`** - logs a finished AMRAP (completed rounds + reps into the next round, filled across movements in prescription order) so BTWB stores a real AMRAP score. `loads` overrides load by position for scaled results. **Always posted with Privacy: Only Me.**
- **`log_ascending_amreps_workout(workoutId, workoutSlug, rounds, extraReps, performedDate, rxd, loads, notes, trackEventId)`** - logs a finished ascending-ladder AMRAP (an "AMReps 2-4-6-8-10..." workout, BTWB type `amreps/ascending`, scored in total reps). `rounds` is the number of completed rounds, `extraReps` the reps done in the unfinished round (filled across movements in prescription order); the tool returns the `totalReps` BTWB stores. `loads` overrides load by position for scaled results. **Always posted with Privacy: Only Me.**
- **`create_multi_emom_workout(everySeconds, untilSeconds, movements, name, description)`** - defines an EMOM where ALL the movements are done inside every interval (e.g. "Every 1:00 for 6:00: 10 Bench Press at 135 + 3 Strict Pull-ups", BTWB type `multiple/emom`, scored in rounds). Find-or-create like the other builders; without a `name` a no-match returns `needsName` instead of creating anything.
- **`log_multi_emom_workout(workoutId, workoutSlug, rounds, performedDate, rxd, loads, notes, trackEventId)`** - logs a finished `multiple/emom` workout: `rounds` is the number of intervals completed, 1 to ceil(until / every). `loads` overrides load by position for scaled results. Not for alternating EMOMs (`alternating/emom`, which has no logger yet). **Always posted with Privacy: Only Me.**
- **`log_time_each_round_workout(workoutId, workoutSlug, roundTimesSeconds, performedDate, rxd, loads, notes, trackEventId)`** - logs a `timeEachRound` result (e.g. "3x RFT: DB Bench Press, Devil Press, Wall Walks" where every round is timed): one time per round, in order; BTWB totals them. `loads` overrides load by position for scaled results. **Always posted with Privacy: Only Me.**
- **`log_effort_sets_workout(workoutId, workoutSlug, sets, performedDate, rxd, notes, trackEventId)`** - logs a `gymnastics/sets` result (e.g. "Strict Ring Dips: 10x max rep", scored in total reps) or a `monostructural/sets` result (e.g. "Row: 4x 1500 m, rest 2:30", scored in total time or distance). `sets` has one entry per prescribed set, in order, with `reps`, `timeSeconds`, `distance` or `weight` as that workout asks (see `get_workout_prescription`). **Always posted with Privacy: Only Me.**
- **`log_rounds_max_reps_workout(workoutId, workoutSlug, roundScores, performedDate, rxd, loads, notes, trackEventId)`** - logs a `roundsForMaxReps` result (e.g. "5 RF-ME: Front Squats and Strict Pull-ups"): `roundScores` is the max reps each round (a number for one max-rep movement, or an array per round for several, in prescription order). A pick-load movement needs its weight in `loads` at its position. BTWB totals the score. **Always posted with Privacy: Only Me.**
- **`log_timed_rounds_workout(workoutId, workoutSlug, roundScores, performedDate, rxd, notes, trackEventId)`** - logs a timed-rounds, max-reps result (e.g. a class RemReps piece: 5 x 1 min of 10 burpees and max Echo Bike calories). `roundScores` is the max-rep score per round in order (calories per round); BTWB totals them, and fixed-rep movements stay as prescribed, so note any difference in `notes`. **Always posted with Privacy: Only Me.**
- **`get_workout_prescription(workoutId, workoutSlug, performedDate)`** - read-only: returns a prescribed workout's raw prescription JSON (`prescriptionType`, `scoring`, `prescription`, `contents`) from its own Log Result page, without posting anything. Use this to see the exact shape BTWB uses for a workout type before writing or extending a `log_*` tool for it - every logger above was built by inspecting this same JSON. Posts no write, so there's nothing to clean up afterward.
- **`log_for_time_workout(workoutId, workoutSlug, totalTimeSeconds, performedDate, rxd, loads, notes, trackEventId)`** - logs a finished For Time result (with or without a time cap) against that prescribed workout, e.g. a class track's chipper, optionally linked to a track event. `loads` overrides a movement's load by position for scaled results. For a time-capped chipper (`forTime/timeCap`, e.g. "Room on the Broom") pass `totalTimeSeconds` if you finished, or `repsCompleted` if the cap hit; plain For Time workouts still only support finishes. **Always posted with Privacy: Only Me.**
- **`get_movement_history(memberId, movementId, movementSlug, days)`** - pulls the full logged history for a movement over a date range: every individual set (date, reps, weight), not just PRs, plus a computed "Potential Max" trend line. `memberId` is optional and defaults to the signed-in member.
- **`get_member_id()`** - returns the signed-in member's own numeric member ID, read from the "Analyze Dashboard" link in BTWB's navigation menu.
- **`get_workout_session(sessionId)`** - fetches details of an already-logged result by its session ID.
- **`delete_workout_session(sessionId)`** - permanently deletes an already-logged result by its session ID. No undo.
- **`refresh_session_cookie()`** - manually re-authenticates and replaces the stored session cookie. Every other tool already does this automatically on an expired session (see "Automatic cookie refresh" below) - this is mainly for testing your setup or forcing an early refresh.
### Known gaps: workout types with no logger yet
Every `log_*` tool above checks the real `prescription.type` BTWB sends back for that exact workout, and only accepts the type string(s) it's actually been verified against - a tight match, never a prefix or substring test, so an unverified sibling type fails with a clear error instead of silently posting something structurally wrong.
BTWB's own `prescriptionType` enum has **21 values**, found in its builder JS bundle and captured in [issue #7](https://github.com/jchan023/btwb-mcp/issues/7). This project currently covers 13 of them - `weightlifting/sets`, `single/emom`, `forTime`, `roundsForTime`, `forTime/timeCap`, `timeEachRound`, `roundsForMaxReps`, `bookends`, `amrap`, `amreps/ascending`, `timedRounds/maxReps`, `gymnastics/sets`, `monostructural/sets` - each one added only after being hit for real in a live session and inspected. The rest:
- **In the taxonomy, wire format unconfirmed:** `alternating-emom`, `amrapBuyIn`, `amrapMaxRep`, `amrapRepeats`, `amreps` (plain, non-ascending), `deathby-reps`, `fgb`, `forQuality`, `multiple-emom`, `rftVaryingReps`, `roundsForMaxReps`, `tabata`, `timeEachRound`, `twentyOne`.
Use **`get_workout_prescription(workoutId, workoutSlug, performedDate)`** on a real workout of one of these types to see its exact prescription JSON (read-only, posts nothing), then add a logger for it following the pattern of the existing `log_*` functions in `src/btwb-client.js` - see the "Known BTWB prescription types" comment block there for the full picture, including the hyphen-to-slash pattern observed so far between the JS bundle's names and the actual wire format. As with any other write here, verify it against a real (then-deleted) throwaway entry before trusting it - there's no BTWB sandbox to test against instead (see Contributing).
## Setup
### 1. Install dependencies
```bash
npm install
```
### 2. Get your session cookie into Keychain
1. Log into [beyondthewhiteboard.com](https://beyondthewhiteboard.com) in your browser.
2. Open DevTools → Network tab, reload the page.
3. Click any request to `beyondthewhiteboard.com`.
4. Copy the full value of the `Cookie` request header.
5. Store it directly in Keychain (run this yourself in a terminal - never paste your cookie into a chat/AI session):
```bash
security add-generic-password -a "$USER" -s "btwb-session-cookie" -A -U -w "your_cookie_here"
```
This cookie is tied to your login session. If tools start failing with a CSRF/session error, it has expired - repeat these steps for a fresh one, or set up automatic refresh below so you never have to.
### 3. Register with Claude Code
Add to your `.mcp.json` (project-level or global):
```json
{
"mcpServers": {
"btwb": {
"command": "node",
"args": ["/absolute/path/to/btwb-mcp/src/index.js"]
}
}
}
```
No `env` block needed - the cookie lives in Keychain, not in config. Or via the CLI:
```bash
claude mcp add btwb -- node /absolute/path/to/btwb-mcp/src/index.js
```
### 4. (Optional) Enable automatic cookie refresh
By default, when your session cookie expires you refresh it by hand (repeat step 2). Optionally, you can let the server re-authenticate for you automatically whenever it detects an expired session - every tool call transparently retries once through a fresh login if needed, so you never have to touch DevTools again. This has been verified working end-to-end (login → fresh cookie → Keychain update → live authenticated request).
This requires storing your actual BTWB **password** (not just a session cookie) in Keychain. Weigh that before opting in - see the caveats below.
1. Store your BTWB password in Keychain (run this yourself in a terminal - never paste your password into a chat/AI session):
```bash
security add-generic-password -a "$USER" -s "btwb-password" -A -w
```
`-w` with nothing after it makes `security` prompt for the password on a separate line with hidden input - it's never part of the command itself, so it's never echoed and never saved to shell history. (Prefer a GUI? Keychain Access.app → File → New Password Item → name `btwb-password`, account = your Mac username, works identically.)
Note: `-a "$USER"` is just the Keychain lookup key the code uses internally (your Mac account name) - it isn't your BTWB login and doesn't need to match your email.
**Running this on a Claude cloud session (Claude Code on the web, a remote/cloud environment) instead of your own Mac?** Those run in a Linux container with no Keychain at all, so `security` can't work there. Set the `BTWB_PASSWORD` env var instead - the server tries Keychain first and falls back to it automatically. Since a password doesn't go stale the way a copied cookie does, this doesn't reintroduce the "stale cookie in an env var" problem Keychain-only storage was chosen to avoid; the cookie itself still only ever lives in memory for that process, never in an env var or on disk.
2. Set `BTWB_EMAIL` to your BTWB login email (this one isn't sensitive on its own, unlike the password). Since GUI-launched MCP clients don't source your shell profile, the reliable place is your `.mcp.json`'s `env` block, alongside the cookie:
```json
{
"mcpServers": {
"btwb": {
"command": "node",
"args": ["/absolute/path/to/btwb-mcp/src/index.js"],
"env": {
"BTWB_EMAIL": "you@example.com"
}
}
}
}
```
(`.env` or `export BTWB_EMAIL=...` also work for terminal-launched sessions. On a Claude cloud session, add `BTWB_PASSWORD` as an environment secret rather than committing it to `.mcp.json`.)
3. Restart the MCP server. `refresh_session_cookie` (or any other tool, automatically, whenever it detects an expired session) will now sign in with those credentials and overwrite the stored session cookie.
**Caveats:**
- This stores a second, more sensitive secret (your actual login password) in Keychain (or, on a Claude cloud session, in `BTWB_PASSWORD`), not just a session token.
- It depends on BTWB's `/signin` → `/session` login form staying script-friendly. If BTWB ever adds a CAPTCHA or 2FA step, automatic refresh will start failing (with a clear error, not silently) and you'll fall back to the manual method.
- On a Claude cloud session, the refreshed cookie is cached in memory only and re-fetched on every process restart (one extra login) - it can't be persisted the way it is on macOS.
- Don't want this? Just skip this step - everything else works exactly as before, you'll just refresh the cookie by hand when it expires.
## Privacy
Every workout result this server logs is posted with **Privacy: Only Me**, hardcoded in the client, not passed as a parameter. The one exception is `log_weigh_in`: BTWB's Weigh-Ins form has no privacy field at all, so weigh-ins follow your BTWB account settings. If you ever need a differently-scoped post, do it by hand in the BTWB app rather than changing this server's default.
## How the endpoints were found
Documented in commit history / session notes: found by watching Network tab traffic in a real logged-in browser session while performing each action (searching a movement, submitting the "Log Result" form, viewing a movement's PR page), then reading the resulting request URLs and the log form's actual field names directly out of the page DOM.
- Search: `GET /exercises/autocomplete_name.json?posting_trait=true&term={term}`
- Log (single movement): `POST /workouts/logger` (form-encoded, CSRF-protected, `workout_session[definition]` JSON)
- Log (multi-movement/rounds): `POST /workouts/{workoutId}-{slug}/workout_sessions` (form-encoded, CSRF-protected, `workout_session[uiobject]` JSON - a different field name and shape than the single-movement flow)
- Weigh-in: `GET /members/{memberId}/weigh_ins/new` (CSRF token + stored height) then `POST /weigh_ins` (form-encoded: `weigh_in[weighed_in_at(1i-5i)]` date/hour/minute parts, `weigh_in[weight]`, `weigh_in[height]`, `weigh_in[metric]`, optional `weigh_in[percent_body_fat]`, `weigh_in[notes]`)
- History: `GET /members/{memberId}/movements/{movementId}-{slug}/vmax?d={seconds}`
- Single session detail: `GET /workout_sessions/{id}` (HTML scrape - no JSON endpoint)
- Delete: `DELETE /workout_sessions/{id}` (CSRF-protected, same endpoint as the app's own "Delete" UJS links)
- Sign in (for automatic cookie refresh): `GET /signin` (pre-login session cookie + CSRF token) then `POST /session` (form-encoded: `login`, `password`, `authenticity_token`, `remember_me`)
## Contributing
Bug reports and PRs are welcome - see [CONTRIBUTING.md](CONTRIBUTING.md) for how this project is tested (there's no automated test suite) and what to include in a report.
## Security
Found a security issue (e.g. a way this could leak your session cookie)? See [SECURITY.md](SECURITY.md) for how to report it privately.
## License
[MIT](LICENSE)
TDQS
Scored across 7 tools
Each tool targets a distinct resource and action: search_movement (library lookup) vs get_movement_history (per-set history), log_workout (single movement) vs log_rounds_workout (multi-movement rounds), get_workout_session (read) vs delete_workout_session (remove), plus a standalone auth utility. Descriptions proactively clarify overlaps, e.g. log_rounds_workout explicitly contrasts itself with log_workout.
All seven tools use consistent snake_case verb_noun phrasing (search_movement, log_workout, get_workout_session, delete_workout_session, refresh_session_cookie). No style mixing or vague verb-only names.
Seven tools is well-scoped for a workout-logging and movement-data integration; each tool earns its place with a clear role and no redundant entries.
The surface covers search, log (two variants), read, and delete, but has no update/edit for an already-logged result, and get_workout_session requires a known session ID with no search-by-date or list-sessions tool. Discovery of existing sessions/workouts is thus a notable gap.