An unofficial MCP 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 (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.
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).
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 insrc/btwb-client.jsand 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 aslog_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'strackEventId,workoutIdandworkoutSlug. Events that already have a result come back asloggedwith theirsessionId. Optionaltrackname filter (e.g."class").log_sets_workout(workoutId, workoutSlug, sets, performedDate, rxd, notes, trackEventId)- logs a set-by-set lifting result (aweightlifting/setsworkout, 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_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.loadsoverrides a movement's load by position for scaled results. Capped (unfinished) results aren't supported yet. 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.memberIdis 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.
npm install-
Log into beyondthewhiteboard.com in your browser.
-
Open DevTools → Network tab, reload the page.
-
Click any request to
beyondthewhiteboard.com. -
Copy the full value of the
Cookierequest header. -
Store it directly in Keychain (run this yourself in a terminal - never paste your cookie into a chat/AI session):
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.
Add to your .mcp.json (project-level or global):
{
"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:
claude mcp add btwb -- node /absolute/path/to/btwb-mcp/src/index.jsBy 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.
-
Store your BTWB password in Keychain (run this yourself in a terminal - never paste your password into a chat/AI session):
security add-generic-password -a "$USER" -s "btwb-password" -A -w
-wwith nothing after it makessecurityprompt 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 → namebtwb-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
securitycan't work there. Set theBTWB_PASSWORDenv 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. -
Set
BTWB_EMAILto 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'senvblock, alongside the cookie:{ "mcpServers": { "btwb": { "command": "node", "args": ["/absolute/path/to/btwb-mcp/src/index.js"], "env": { "BTWB_EMAIL": "you@example.com" } } } }(
.envorexport BTWB_EMAIL=...also work for terminal-launched sessions. On a Claude cloud session, addBTWB_PASSWORDas an environment secret rather than committing it to.mcp.json.) -
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→/sessionlogin 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.
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.
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) thenPOST /weigh_ins(form-encoded:weigh_in[weighed_in_at(1i-5i)]date/hour/minute parts,weigh_in[weight],weigh_in[height],weigh_in[metric], optionalweigh_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) thenPOST /session(form-encoded:login,password,authenticity_token,remember_me)
Bug reports and PRs are welcome - see CONTRIBUTING.md for how this project is tested (there's no automated test suite) and what to include in a report.
Found a security issue (e.g. a way this could leak your session cookie)? See SECURITY.md for how to report it privately.