Skip to main content
Glama

btwb-mcp

License: MIT Node.js >= 18 MCP

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.

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).

Related MCP server: hevy-mcp-server

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

npm install
  1. Log into 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):

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

{
  "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.js

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

    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:

    {
      "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 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 for how to report it privately.

License

MIT

Available Tools

7 tools
delete_workout_sessionA

Permanently delete an already-logged BTWB result by its session ID. This cannot be undone - BTWB has no trash/undo for deleted sessions.

Inputs: sessionId (required).

get_movement_historyA

Get 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. Requires the BTWB member ID and the movement's numeric ID plus its URL slug (e.g. movementId 35, movementSlug 'deadlift' for beyondthewhiteboard.com/.../35-deadlift).

Inputs: memberId (required), movementId (required), movementSlug (required), days.

get_workout_sessionA

Get the full details of one already-logged BTWB result by its session ID (the number in a beyondthewhiteboard.com/workout_sessions/{id} URL): workout name, performed date/time, the movements/sets, the result/score, and level/WOD-rank stats. There's no search-by-date endpoint yet - you need the session ID already (e.g. from a URL, or from log_workout's redirectedTo field).

Inputs: sessionId (required).

log_rounds_workoutA

Log a multi-movement 'rounds' result (e.g. a For Time WOD with several movements per round) to BTWB - as opposed to log_workout, which only handles a single movement. Only 'For Time' workouts scored by total time are supported (other scoring types like AMRAP/total-reps are untested). workoutId/workoutSlug come from the workout's URL (beyondthewhiteboard.com/workouts/{workoutId}-{workoutSlug}/...). Every entry logged through this tool is always posted with Privacy: Only Me - this is hardcoded and cannot be overridden.

Inputs: rxd (required), memberId (required), sections (required), workoutId (required), +5 more.

log_workoutA

Log a single-movement result (e.g. a 1-rep max) to BTWB. Every entry logged through this tool is always posted with Privacy: Only Me - this is hardcoded and cannot be overridden.

Inputs: reps (required), weight (required), movementId (required), movementName (required), +3 more.

search_movementA

Search BTWB's movement library by name (e.g. 'Deadlift', 'Squat Clean'). Returns matching movements with their numeric IDs, which log_workout and get_movement_history both require.

Inputs: term (required).

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A4.2/5.0

    Scored across 7 tools

    Disambiguation5/5

    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.

    Naming Consistency5/5

    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.

    Tool Count5/5

    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.

    Completeness3/5

    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.

    Maintenance

    ActivityNo data
    ResponsivenessOutdated data

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables LLMs to read and write Hevy workout tracking data, including workouts, routines, exercise templates, per-exercise history, and body measurements through all 15 Hevy public API endpoints.
      16 npm
      MIT
    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables AI assistants to plan, build, and log calisthenics workouts in a user's own account, including searching exercises, managing custom workouts and plans, reading progress and coaching data, and logging completed sessions. Account mutations require explicit confirmation, and workouts can be displayed as interactive cards in the chat.
      MIT
    • F
      license
      Not graded
      quality
      A
      maintenance
      Connects AI assistants to a TrainingPeaks account via secure cookie authentication—no API approval required—so users can query and build workouts, schedule and copy sessions, manage calendar events and notes, log health metrics, and review CTL/ATL/TSB fitness trends, power PRs and zone settings through natural language. It also covers strength workouts, equipment, workout libraries, multi-week training plans, and coach features such as athlete groups.
      -