Spot is a workout tracker where a person and an AI agent work on the same live session, in the same browser tab. Most AI fitness apps just talk at you. They write you a plan and leave. Spot lets an agent actually execute that plan, and work on the session with you. Tell you when to progress, and when to ease off.

The app you use at the gym, and the tools the agent uses, hit the same data. All your lifting history, preferences, and personal context can factor into your lifts. Spot makes the plan, you log the sets.

Live at https://spot.mootoo.co — no login. Source under AGPL-3.0 at https://github.com/moolikethecow/webmcp-workout.

Why this use case fits WebMCP

A workout is a living document that changes every ninety seconds while you're in the middle of it. A chat model can't hold that state: it remembers what you said, not what you did. The tracker holds the truth but can't reason with you. WebMCP puts the agent inside the page that owns the truth. Tools register on document.modelContext from the page you're actually looking at, run in your own browser session, and read and write the exact session the logger renders. No API keys, no sync, no drift.

How it creates a better user experience

You train. You tap sets done. Then you say "my shoulder's bugging me and I've got 30 minutes, keep what I've done, work around the shoulder, hit whatever's freshest" — and the remaining exercises change while the sets you finished stay exactly as you logged them. The replacements weren't hallucinated: the app filtered a 1,318-movement catalog by your stated constraint and your equipment, and the agent picked from that pool.

Ask why the next incline press session says 80 lb and the answer cites your last six sessions and the double-progression rule that produced it. Ask it to log a set and it won't invent the numbers — it asks what you actually lifted.

What people and agents can do together that was difficult or impossible before

  • Restructure a workout mid-session without losing what's been done. Every write carries the session revision; a stale edit is rejected with current state and the agent re-reads.
  • State a training constraint once and have it enforced everywhere — search, drafts and live edits all pass through one eligibility function over a precomputed biomechanical demand profile for every exercise in the catalog.
  • Choose today's work from per-muscle readiness derived from your own history, not generic advice.
  • Get progression answers that are auditable: explicit policy in, next target out, rule quoted.
  • Say "I'm in a hotel gym, dumbbells and a smith machine only" and have the catalog narrow to the room you're standing in. A gym never recorded before is created from the description.
  • Let an agent stage a claim about your body without being able to assert it. The training-constraint tool is a form: the agent fills it, the call stays pending, and it completes when you press Add.

The human is better at feeling how a set went and calling an audible. The agent is better at querying six weeks of history, reconciling constraints with readiness, and doing the arithmetic. The page is where they meet.

How WebMCP is implemented

Both halves of the API are used, and the split between them is the design.

Sixteen page-scoped tools are registered with document.modelContext.registerTool by a React hook on mount and unregistered through an AbortController on navigation: get_training_context, get_active_workout, edit_active_workout, log_active_sets, search_exercises, get_muscle_readiness, get_training_constraints, set_training_constraint, draft_workout, edit_workout_draft, start_workout, get_exercise_progress, get_workout_history, get_training_plan, list_gyms, switch_gym.

Each execute performs a same-origin fetch to the app's own API routes, which call the same library functions the UI uses, then invalidates the page's data so the logger refetches. Mutations pass expected_revision and return the new revision. Read tools carry readOnlyHint. Descriptions carry the invariants: completed sets preserved, warm-ups are not working volume, substitutions come from the eligible pool only, constraints are hard limits, no diagnosis. get_training_context returns the collaboration rules so an agent has a one-call orientation. The tool list is page-scoped because a tool list is a prompt: edit_active_workout and log_active_sets aren't offered where there's no live session.

One declarative tool, registered by markup. report_training_constraint is a <form> on the dashboard carrying toolname and tooldescription. There's no registerTool call for it — Chrome reads the controls and derives the schema: required becomes JSON Schema required, a <select> becomes an enum carrying its option labels.

The reason to use a form is what it does to timing. Chrome fills the fields and then waits. The call doesn't resolve until the form is actually submitted, so an agent can put shoulder_joint / limiting / left shoulder on screen but a person commits it. SubmitEvent exposes agentInvoked and respondWith(), so one handler serves both callers.

ChatGPT's built-in browser implements a subset of WebMCP with no declarative API, so a <form toolname> isn't a site tool there. Rather than lose that beat in the client judges are told to use, the page treats the form as the definition and the declarative API as one way to publish it: where the browser hasn't published the form, the same report_training_constraint is registered in code, and its execute fills the same form and waits for the person's press. Chrome's indefinite wait is the one thing registerTool can't reproduce, so after twenty seconds the call resolves with awaiting_confirmation and the values stay on screen for the press. One name, one form, one submit handler, three callers.

That gives the app an honest line: what the agent may do alone is registered in code; what needs a hand on the button is a form. Reading, searching, drafting and re-prescribing work not yet done are the first kind. Asserting a limit on your own body is the second. And because it's a real form, a browser with no WebMCP gets an ordinary constraint editor.

Every visitor gets an isolated Postgres schema seeded with a fictional athlete's six weeks of training, so judges can mutate freely and reset from Settings.

Inspiration

I already had a workout tracker I'd built for myself. What I didn't have was any way for an agent to actually use it. When I injured my shoulder, there was no way to change my workouts on the fly, or update my progressions. Every AI fitness tool I tried would happily write me a program and then have no idea what I did in the gym, and couldn't adapt. I wanted to close the loop. Building the app with WebMCP now means that Spot can plan my workouts, monitor my lifts inside the workout with me, look at my results, adjust my plans, and frankly, just handle my workouts.

How I built it

Next.js 15 and React 19 over Postgres, with every query written as SQL through Drizzle rather than an ORM abstraction. Each visitor gets their own Postgres schema, provisioned on first request and pinned per connection, so the app stays single-tenant code and all the isolation lives in one file. The training engine is deterministic (constraint eligibility, readiness and progression are pure functions over logged sets) and covered by around 1,400 tests, which is what made it safe to let an agent write to a live session at all.

What I learned

A tool description is a prompt, and so is the tool list. Page-scoping which tools exist changed agent behaviour more than any wording change I made.

The bigger lesson: a tool's success message can lie. Three of mine reported work they had not done. Search claimed to apply an equipment filter it never ran, eligibility counts were taken over a sample but presented as the whole catalog, and a constraint that excluded nothing said it had excluded things. All three passed their tests. They surfaced only by driving the deployed app and reading what the tool actually said, rather than checking that it returned ok.

Challenges

ChatGPT's WebMCP is a subset with no getTools and no declarative forms, so the form tool would not exist at all in the client judges are told to use. The page now detects that and registers a code-defined stand-in that fills the same form and still waits for a human to press the button.

Chrome cancels a pending declarative call if the form is reset, which meant an agent-invoked submit had to leave the values on screen rather than clearing them.

Where it comes from

Spot is the open agent surface of a larger health ecosystem app I built. The logger and the deterministic training engine were extracted from it; the WebMCP layer, per-visitor workspaces and the demo athlete were built for this challenge. docs/PRIOR_WORK.md in the repo draws the exact line, with commit timestamps.

Spot only sees the gym. The system it came from sees the week around it — sleep, food, habits, schedule, and more. That is out of scope here on purpose, but if you're interested you can check it out at https://waitlist.mootoo.co.

Built With

Share this project:

Updates

Submission history