Dev Blog

BLOG

Controlling our metronome from Chrome's built-in AI with WebMCP

We registered Metronome.fm's controls as WebMCP tools so Chrome's built-in model can handle requests like "180 BPM in 7/8". The small model said it had made the change when nothing had changed, so we reworked how tools report failure to the AI, how they are split, and how their descriptions are written.

Published October 10, 2026

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.

The WebMCP for testing entry in chrome://flags. Number 1 is the select box set to Enabled
WebMCP for testing in chrome://flags
  1. 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.

The Prompt API Tool Use entry in chrome://flags. Number 1 is the select box set to Enabled
Prompt API Tool Use in chrome://flags
  1. Set to Enabled so the built-in model can call tools
The settings screen of webmcp-extension. Number 1 is the Sensitive action alerts switch and number 2 is the On-device model switch. The download progress of the built-in model is shown at the top
The extension's settings
  1. Asks for permission before calling a tool that changes the page
  2. 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 side panel lists three descriptions of the tools Metronome.fm registered (number 1). Number 2 is the icon showing that the page has tools
Tool descriptions in the side panel (first version)
  1. The descriptions of our tools. The model reads these to choose a tool
  2. 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 Allow tool actions screen in the side panel. Number 1 is the name and description of the tool about to be called, and number 2 is the Don't allow, Allow, and Allow on metronome.fm for this chat buttons
The prompt before calling a tool that changes the page
  1. The tool about to be called and its description
  2. 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".

On the left is the metronome screen; number 1 shows it is still at 120 BPM in 4/4. On the right is the side panel; number 2 is the reply saying it started the metronome at 180 BPM in 7/8, 3+2+2, and number 3 is the reply to the question about the current settings, saying BPM 120 and 4/4
It said it had started, but the settings had not changed
  1. The screen is still at 120 BPM in 4/4
  2. It replied that it had started at 180 BPM in 7/8, 3+2+2
  3. 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.

At the bottom left is the page's DevTools console; number 1 is the Invalid input error where the tool did not accept the input. On the right is the side panel; number 2 is the reply saying it had set 180 BPM, 7/8, grouping 3+2+2
The tool did not accept the input, but the reply said it was set
  1. The tool could not read the accent values and rejected the input
  2. 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.

A reply in the side panel. Number 1 is the part saying it tried to make the change but could not apply the settings correctly, and that the settings were not applied because of an error about the number of accents
It now reports the failure
  1. 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.

On the left is the metronome screen now at 180 BPM in 7/8. On the right is the side panel; number 1 is the record of calling two tools, Set metronome tempo and Start metronome, and number 2 is the reply saying it started the metronome
The settings changed, but it also started playback unasked
  1. After setting the tempo, it also called the playback tool
  2. 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.

On the left is the metronome screen at 180 BPM in 7/8 with accents on beats 1, 4, and 6 (number 1). On the right is the side panel; number 2 is the reply saying it set 7/8 with 3+2+2 and asking whether to start
It changed only the settings and checked before playing
  1. Now at 180 BPM in 7/8, with accents on the head of each 3+2+2 group (beats 1, 4, and 6)
  2. 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.

Share this post

Post on X