We made Metronome.fm work with WebMCP, so an AI agent running in the browser can take a request like "180 BPM in 7/8, 3+2+2" (7 beats grouped as 3, 2 and 2). We used a Chrome extension as the agent and had it use the small model built into Chrome. At first, it failed again and again. It said "Done" when nothing had changed, and when asked to "raise the BPM by 10", it lowered it by 10.
This post covers how we register WebMCP tools and what we changed until they worked with the built-in model.
What WebMCP is
WebMCP lets a web page register "the things you can do on this page" with the browser as tools. Instead of reading the screen and pressing buttons, an agent calls the registered tools directly. A tool is a name, a description, a JSON Schema for its input, and a function to run, registered from JavaScript. Nothing is needed on the server side.
As of October 2026, Chrome is still testing it in an origin trial (Chrome 149 to 156). The major agents (Gemini in Chrome, Claude, ChatGPT) do not call WebMCP tools yet. Google has announced that Gemini in Chrome will support it, but there is no date. Since nobody will use it right away, we decided to try it now on a small scale, without building much.
We thought Metronome.fm was a good fit for WebMCP. The app keeps the actions behind our keyboard shortcuts and menus in one place, each with its own ID. Most tools can be built just by calling those actions.
Registering tools
We started with four tools: get the state, set the tempo, time signature and accents (whether each click is strong), start playback (optionally stopping after a number of bars, seconds or minutes), and stop.
In Chrome 150, the registration point moved from navigator.modelContext to document.modelContext. The spec may still change, so all the code that touches modelContext lives in one file.
export const getModelContext = (): ModelContext | null => {
if (typeof document === "undefined") return null;
const fromDocument: unknown = Reflect.get(document, "modelContext");
if (isModelContext(fromDocument)) return fromDocument;
// Fall back to the old name
const fromNavigator: unknown = Reflect.get(navigator, "modelContext");
return isModelContext(fromNavigator) ? fromNavigator : null;
};
// Aborting the signal unregisters the tool
modelContext.registerTool(tool, { signal });Tools are registered only while the metronome screen is shown. When you move to the docs or the blog, an AbortController unregisters them. Browsers without WebMCP are left alone.
The agent writes the tool input, so we don't take it on trust and validate all of it with zod. The JSON Schema we give the agent is generated from the same zod definition with z.toJSONSchema. Time signatures come in as strings like "7/8" and "3+2+2/8", the same notation the app uses when it saves settings.
const setTempoInputSchema = z.object({
bpm: z.number().int().min(40).max(300).optional(),
// "4/4", "6/8", "3+2+2/8" (with a beat grouping)
timeSignature: z
.string()
.refine((value) => parseMeterKey(value) !== null)
.optional(),
});
const tool = {
name: "set_metronome_tempo",
description: "Set the tempo (BPM) and/or the time signature. ...",
inputSchema: z.toJSONSchema(setTempoInputSchema, { io: "input" }),
execute: async (input) => {
/* Check, apply to the store, and return the current state as a JSON string */
},
};Playback goes through the same code as the play button on screen. The playback time limit for each plan (free or Pro) applies just as it does when you press the button.
Getting ready to try it
We used Chrome 154. First, turn on WebMCP in chrome://flags.

- Change it from Default to Enabled
With the flag on, document.modelContext is available even on sites that are not registered for the origin trial.
We first listed the tools with getTools() from the DevTools console and called one with executeTool(). One thing tripped us up here. In Chrome 154, passing the input to executeTool as an object fails with Failed to parse input arguments. Passing it as a JSON string works. In Chrome 155, it is the other way around: the string form is deprecated.
const [tool] = await document.modelContext.getTools();
await document.modelContext.executeTool(tool, JSON.stringify({ bpm: 180 }));To have an agent call the tools, we used webmcp-extension, an experimental extension from Google's Chrome Labs. Following the README, we cloned the repository, built it, and loaded it from chrome://extensions. You chat with an agent in a side panel, and it calls the page's tools.
The agent can also use a model on a server that holds an API key, but we chose Chrome's built-in model (called through the Prompt API), which needs no key. Letting the built-in model call tools needs one more flag.

- Set to Enabled so the built-in model can call tools

- Asks for permission before calling a tool that changes the page
- Uses the built-in model. Turning it on for the first time starts downloading the model
When we opened Metronome.fm and the side panel, the descriptions of our tools were listed.

- The descriptions of our tools. The model reads these to choose a tool
- The icon showing that this page has WebMCP tools
Before calling a tool that changes the page, the extension asks for permission. We tell it what kind of tool it is (read-only or not) with readOnlyHint in annotations when registering. Only the state tool has readOnlyHint: true, so the other tools show a prompt.

- The tool about to be called and its description
- Don't allow, allow once, or allow for this chat
"Done", but nothing changed
We first asked for "180 BPM in 7/8, 3+2+2". The reply said it had started the metronome at 180 BPM in 7/8, 3+2+2. But the screen was still at 120 BPM in 4/4, and not playing. When we asked for the current settings, the agent itself answered "BPM: 120, time signature: 4/4, stopped".

- The screen is still at 120 BPM in 4/4
- It replied that it had started at 180 BPM in 7/8, 3+2+2
- Asked for the current settings, the agent itself answered 120 and 4/4
To find out why, we opened the page's DevTools and asked again. The tool had been called, and the page's console showed an error where our tool did not accept the input. The model had added accents that nobody asked for, and written the values in a broken form (wrapping words in quotes or brackets, for example). The tool changed nothing, yet the model replied that it had set everything.

- The tool could not read the accent values and rejected the input
- It replied that it had set everything even though the input was rejected
In a separate request, "raise the BPM by 10", the tool ran and the BPM changed. But it went down from 120 to 110. The tool only accepted an absolute BPM, so the model had to read the current value and do the addition, and that is where it seems to have gone wrong.
What we changed for the small model
Report unaccepted input in the result
When they could not accept the input, our tools threw an exception. Whether the message of a thrown exception reaches the model depends on the browser and the extension, and the page cannot control it. In practice, the model had not noticed that its input was not accepted.
So instead of throwing, we return a result string that says nothing was changed.
const withInputErrors =
(execute: ModelContextTool["execute"]): ModelContextTool["execute"] =>
async (input, context) => {
try {
return await execute(input, context);
} catch (error) {
if (!(error instanceof ToolInputError)) throw error;
return JSON.stringify({
error: error.message,
message:
"Nothing was changed. Fix the input and call the tool again, or tell the user it failed.",
state: getMetronomeStateSnapshot(),
});
}
};We also accept broken values as far as we can read them. If an accent value contains the word accent, normal, or mute, we accept it even when it is wrapped in quotes or brackets. The BPM can now also be given as a change from the current value (bpmChange). "Raise it by 10" becomes { bpmChange: 10 }, and the tool does the addition.
After these changes, the same request got an honest reply: the model said it could not apply the settings correctly. The model also understood why its input was not accepted.

- It reports the failure and the reason (the number of accents)
The settings still did not change, though. The model again added accents nobody asked for, and this time could not make the number of accents match the number of beats (7 items for 7 beats). Writing "only when the user asks" in the description did not stop it.
Move optional fields into a separate tool
A small model seems to fill in fields even when it could leave them out. So we removed accents from the tempo tool and moved them into a separate tool, set_metronome_accents. The accent field is no longer part of the tempo tool's input. If the model still sends accents to the tempo tool, we ignore just that field and apply the rest.
With this, asking for "180 BPM in 7/8, 3+2+2" finally changed the settings. But the model also started playback, which nobody asked for.

- After setting the tempo, it also called the playback tool
- It replied that it had started the metronome
Play only when asked
We added to the playback tool's description: call it only when the user asks to play or start, not after only changing settings. When playback cannot start (the page has never been clicked or tapped, or the plan's playback time limit is reached), it now returns an error too, just like the tempo tool.
After that change, the model only changed the settings and asked "Shall I start?" before playing.

- Now at 180 BPM in 7/8, with accents on the head of each 3+2+2 group (beats 1, 4, and 6)
- It changed only the settings and asked whether it could start
The "80 beats" in this reply is a typo by the model. The value shown on screen and passed to the tool was 180. The wording of replies is still rough in places like this.
Sound only after the user has interacted with the page
Browsers do not allow sound until the user has clicked or tapped the page at least once (an AudioContext cannot run). Even if an agent asks to start playback, no sound comes out.
So the playback tool checks navigator.userActivation.hasBeenActive, and when it is false, it does not start and replies "ask the user to click the page once". Even when an agent asks before you have touched the page, the metronome does not start making sound on its own.
When we called the tool from the DevTools console, it played even though we had not clicked the page. We think this is because code typed into the console is treated as a user interaction with the page. We did not check this time how a call from the extension is treated.
Looking back
Registering the tools and making them callable only took calling existing code. The time went into reshaping the tools and their descriptions for a small model.
- Report failure in the result string, not with an exception, and say that nothing changed
- Keep optional fields to a minimum, and split them into another tool if needed
- Do arithmetic in the tool (
bpmChange) - Say in the description when a tool may be called
Metronome.fm already has an AI feature that rebuilds your settings from a written request, and Metronome.fm pays for its model. If WebMCP can do the same, the agent's side pays for the model. Once Gemini in Chrome and others support it, we will add what we find.