The gap
I already had an AI fitness coach. It worked like everyone's does: I'd describe my training week to a chat window, it would give me thoughtful advice, and then I would switch to a different app and type that advice in by hand.
The advice was good. The arrangement was absurd. My coach could reason about my knee but couldn't see that I'd already dropped the weight on Tuesday. I was the integration layer — a human clipboard between two systems that both wanted the same data.
I'd actually tried to fix this before. The app CoRep grew out of had an MCP server bolted onto its Python backend, so my coach could read the database and write to it. It helped, and it also taught me why it wasn't enough: the agent was talking to my database, not to my app. It could change a number, but it couldn't put that number in front of me. I'd find out what it had done later, if I happened to look.
WebMCP inverts that, and the inversion is the whole project.
What it does
CoRep is a training log with no server and no account — every user's data lives in their own browser. It publishes its entire feature set as 31 WebMCP tools, so an agent on the page operates the same application you do.
Not a copy of your plan. Not a screenshot. When the agent raises a weight, it calls the same function your own tap on "Edit" calls, against the same data, and you watch the result land.
Three things fall out of that, and they're the parts I'd point a judge at:
Every change is signed
Writes record who made them and why. The plan view shows both parties in one timeline:
Coach 10→15 lbs · shoulders felt strong, small bump You 15→25 lbs · Edited in the plan editor
Neither of us is the source of truth. This is the single feature I'd keep if I had to throw the rest away. It's what makes it safe to let an agent act rather than only suggest — you can always see what it did, why it thought so, and overrule it.
The nice accident: the app I forked already had an edited_by field and a per-field
edit history, built for that old server-side coach. I'd designed the substrate for this
without knowing it. Turning it into a shared, two-party document was mostly a matter of
letting the second party in.
The agent moves the interface, not just the data
Four tools exist purely to drive the UI: fitness_navigate, fitness_start_session,
fitness_open_plan_editor, fitness_highlight_exercise.
So when the agent changes an exercise, the app switches to the plan tab, scrolls to that exercise, and flashes it. When you say you're ready to train, it opens the guided session flow and you're looking at set one. The agent can say "this one" and have you actually be looking at the thing.
fitness_open_plan_editor is my favourite of the four because it runs the other
direction: it's the agent handing the decision back, opening a blank editor instead of
making a choice that isn't its to make.
Destructive actions stop and ask
delete_plan, delete_exercise and delete_workout don't delete anything. They park
the tool call and render a confirmation card in the app showing exactly what's about to
go. You tap; the agent is told what you chose. Declining is a normal outcome it handles,
and an unanswered prompt counts as "no" after a minute.
I built this in the app's own UI rather than leaning on the spec's
requestUserInteraction(), partly because that surface is still in flux, and partly
because doing it myself meant the confirmation could name the specific plan and its
phase count instead of being a generic browser prompt.
Why WebMCP is the right primitive here
Coaching is a case where an agent and a person genuinely need to co-own one artifact.
A training plan is a living document. It changes because your knee hurt on Tuesday, because the gym was out of 25s, because a set that was brutal last month is easy now. Some of those edits you want to make yourself in three seconds. Some you want to talk through. Both kinds have to land in the same place or the plan stops being true.
Every other arrangement I tried forces a choice: either the agent owns the plan and you file requests with it, or you own the plan and the agent narrates from outside. WebMCP is the first primitive that let me refuse the choice.
What's newly possible: not "an AI that writes you a workout" — that's been available for years and it isn't interesting. It's a plan with two editors and one history, where the reasoning behind every change is attached to the change and visible to both parties. That's a different relationship, and it's one the browser had no way to express until tools could live in the page.
How I built it
Vite + React + TypeScript + CSS Modules. No backend, no state library, no router. Dependencies: React, Lucide icons, and the WebMCP polyfill.
Porting a backend into the browser
The original was FastAPI + SQLAlchemy + SQLite. Making it multi-user meant making it
zero-user: no server at all, one versioned JSON document in localStorage.
The domain layer ported over almost mechanically, because it had been written
framework-free — rules.py, badges.py and service.py became rules.ts, badges.ts
and store.ts. The Python test suite came with it. src/lib/__tests__/ holds 33
tests carried over from pytest, and having a known-green oracle was worth more than the
time it cost, because of this:
Python's
date.weekday()is Monday=0. JavaScript'sgetDay()is Sunday=0. The schedule lookup tables were indexed the Python way. Get this wrong and every streak, completion rate and calendar cell silently shifts by one day — nothing crashes, the numbers are just quietly false.
The ported tests caught it on the first run. I'd have shipped it otherwise.
One choke point makes everything live
The load-bearing decision: db.write() is the only way anything in the app changes, and
it notifies subscribers. Every data hook subscribes.
That's the entire mechanism behind the demo. An agent calls log_workout, and the
streak, the calendar, the stat tiles and the badge wall all update instantly — no
polling, no refresh, and no separate code path for agent-driven changes. Agent writes and
human writes are indistinguishable to the UI, which is exactly the property I wanted.
Registering 31 tools
Worth passing on to anyone else building this: register your tools in parallel.
My first version awaited each registerTool() in a loop. It took several seconds to get
through the set, because the implementation flushes the tool list between calls. An agent
connecting during that window would see a partial toolset — some tools present, some
missing, no error anywhere. I only noticed because I queried getTools() while
debugging and got 11 back instead of 31.
Promise.allSettled over the whole array lets the flushes coalesce. Same 31 tools, 3
milliseconds.
I also hit the spec moving underneath me: the W3C draft relocated the getter from
Navigator to Document in May 2026. The app reads whichever exists:
const mc = document.modelContext ?? navigator.modelContext
Tool descriptions are written for an agent audience, not a changelog. They state units
(pounds, centimetres, seconds), name which tool to call first to get an id, and give
judgement guidance — update_exercise asks for a reason phrased for the user,
because that text is what shows up next to their exercise.
The hardest constraint was the one nobody grades directly
I decided early that CoRep had to be completely usable with no agent connected. The agent should be an amplifier, never a dependency.
This turned out to be the biggest chunk of work in the whole project, for a reason I didn't see coming: in the original app, plans could only be created by the AI. Plan creation existed solely as an MCP tool. There was no UI for it, because there had never needed to be one — I was the only user and I had the coach.
So "works without an agent" meant building the feature an agent-only app never needed: a full manual plan editor. Phases, exercises, reordering, weekday scheduling, the lot.
I'm glad the constraint forced it. An app that collapses when the agent disconnects isn't demonstrating collaboration, it's demonstrating dependence. And the editor made the attribution feature real — before it, every edit in the history said "Coach", and there was nothing to contrast against.
The agent indicator says all this out loud, too. With no WebMCP available it reads: "WebMCP is not available in this browser, so no agent can connect. Everything in the app still works."
What I learned
Agent-facing and human-facing aren't two products. I expected to build an agent layer next to the app. What actually worked was one domain layer with two thin adapters — the same shape the old backend used for REST and MCP. The tools file is 31 descriptions and almost no logic.
Attribution is the unlock for trust. I got noticeably more comfortable letting the agent write things once the app could show me what it had written and why. The confirmation gate matters, but the audit trail matters more, because it works retroactively.
Tool descriptions are a design surface. How I phrased "always pass a reason, written for the user" changed the quality of what showed up in my plan history more than any code I wrote that day.
What's next
- A shared read-only view, so a physio or a coach could read the same history — the attribution model already supports more than two parties.
- Undo built on the edit history, which already contains everything needed for it.
- The polyfill and MCP SDK are most of the 177 kB gzipped bundle; that wants code splitting once WebMCP ships natively.
Try it Open the app and ask your agent:
"Build me a knee-friendly plan for three days a week — I have dumbbells and 30 minutes." "I did today's workout but squats felt heavy, log it." "How's my consistency this month?" "Delete that plan." — and watch it stop and ask you first. Then disconnect the agent and use the whole app by hand.
Built With
- react
- typescript
- vite
- webmcp
Log in or sign up for Devpost to join the conversation.