In this article

Clicking is guessing

A warning before you spend an afternoon on this. WebMCP is an origin trial with exactly one announced consumer, and that consumer has not shipped. Everything below is worth doing anyway, and I will explain why at the end, but do not put it on a roadmap this quarter. With that said, start with what agents do to your product today.

On 23 January 2025 OpenAI shipped Operator, an agent that used its own browser to click, type and scroll through other people’s pages. OpenAI said from the first day that the agent would get stuck on complex interfaces, password fields and CAPTCHA checks, and would ask the person to take over. On 31 August 2025 the standalone site shut down; the capability had been folded into ChatGPT agent on 17 July. The agent was guessing at every step, because the pages never declared what they could do.

A person does not notice when a button moves. They look, find it, and click. An agent driving your product through the screen notices immediately. For it, every step is a guess: locate the table, find the row, open the menu, and match the button by a label that nothing ever promised to keep. The agent’s task is the same as last week’s, but the step that matched on the old label now finds nothing, or finds the wrong element. Nothing announced the change, because the page never declared its actions in the first place.

On the left the agent guesses the next click. On the right it calls the action the page already has.

An agent can work a page in four ways, and they differ in what the agent reads:

ApproachWhat the agent readsWhat breaks it
Driving the screenPixels, or DOM selectorsAny redesign; the guessing above
Reading the accessibility treeThe names and roles assistive technology usesMissing or wrong labels, and it still has to click
A separate tool server (MCP)Your backend APIDrift between the server’s API and the product
WebMCPFunctions the page itself declaresYour own refactors, visible in code review

The first row is not always a mistake. Playwright, the tool teams use to test their interfaces, drives the screen on purpose. A test asks whether a person can still find and press the button, so a broken button must turn the test red; a test that took a shortcut past the screen would prove nothing. An agent asks a different question. It wants the result, the changed number in the row, and it does not care which button does it. The same fragile clicking is therefore right for a test, which is paid to notice a broken screen, and wasted on an agent, which was never asked to check the screen. The browser-testing tools that now offer themselves to agents as MCP servers, prepackaged tool sets an agent can call, sit on the test side of that split: they exist to exercise the screen, not to get around it.

The last row, WebMCP, turns the guessing around: instead of the agent working out what the page can do, the page says it. A page registers its actions as tools, which are just named functions with typed inputs, and an agent calls them by name instead of clicking. The name borrows from MCP, the protocol that standardised how agents call tools on servers. WebMCP does the same inside the page, which removes most of the plumbing: the browser carries the messages, so there is no server to run, and the agent works inside the person’s session, so there is no login to build.

That also draws the boundary against the MCP server you may already run. The page’s tools serve the agent inside a person’s browser session; a headless agent, a CI job, anything acting at scale without a person’s tab, still needs the server route against your API. A team that builds both needs one rule: the backend function is the authoritative one, and both surfaces call it, so the two cannot drift apart.

The proposal itself is young. It lives in a W3C community group, the Web Machine Learning one, and the API names will still change. What will not change is the homework it forces: knowing which actions your product has, and writing them down. That list pays off whichever protocol wins, and it is the part I would not wait on. All of this stays inside the page; what agents do across servers and services is a different layer of its own.

The sharpest objection came from the community itself, filed in the spec’s own tracker: the accessibility tree already describes every control on the page, so why a second channel? Because describing is not the same as doing. The accessibility tree tells you what is there: a button, labelled Save. A tool is something an agent can call, with typed input and a promised result. The two feed each other rather than compete: an action worth declaring for agents is worth exposing to assistive technology too, and declaring tools never excuses a broken accessibility tree.

Declare the verb

The API is one call. One screen is enough to show it: an ordering desk, where someone decides how much of each product to stock. Every row is one product, named by its article number, the SKU. The number the person edits all day is the reorder point: the stock level at which the system orders more. That edit is the verb worth declaring, and the declaration looks like this:

await document.modelContext.registerTool({
  name: "set_reorder_point",
  description:
    "Set the reorder point for one SKU. The person can review and undo.",
  inputSchema: {
    type: "object",
    properties: {
      sku: { type: "string" },
      units: { type: "integer", minimum: 0 },
    },
    required: ["sku", "units"],
  },
  async execute({ sku, units }) {
    await setReorderPoint(sku, units);
    return `Reorder point for ${sku} is now ${units} units.`;
  },
});

Reading it top to bottom: the name and the description are for the agent, or more precisely for its model, which reads them when it decides which tool fits the task. That is why the description ends with “The person can review and undo”: the model may pick this tool freely, knowing a person checks the result. The schema says what the input must look like; treat it as a description, not a gate, and validate inside execute the way any handler validates. And execute is the code the browser runs when the agent actually calls the tool. Whatever string execute returns goes back to the agent as its result, so make it say what changed, not just “ok”.

The important line is the least spectacular one: execute calls setReorderPoint, the same function the Save button calls. That is the answer to the first objection every team raises, that a tool layer is a second implementation waiting to drift from the screen. There is no second implementation here. There is one function with two callers, the button and the agent. Where no such function exists, because the action is smeared across component state and form logic, extracting it is the real adoption cost, and it is a refactor the codebase owed itself anyway. The only thing that can still go stale is the text around it, the schema and the description, so review them when the screen changes, the way you review any label.

A tool should exist exactly as long as its screen. The plumbing for that is an AbortSignal, one more option registerTool accepts beyond what the example shows: create an AbortController when the ordering desk appears, abort it when the desk disappears, and the tool disappears with it. Done this way, an agent only ever sees the actions that are on the screen right now. A separate signal shows up inside each execute call, so a slow operation can stop when its caller gives up. In React, all of it is one small hook: an AbortController per component, created in an effect, aborted in the cleanup.

Scoping tools to screens brings back a familiar problem: the agent has to reach the ordering desk before the desk’s verbs exist, and if reaching it means clicking, the guessing has only moved up a level. So the entry points stay registered for the whole page; opening the desk is itself a verb, and only the desk’s own actions come and go with it.

Many actions already have a declared shape on the page: they are forms. For those, the proposal has a second, cheaper path that writes no JavaScript at all; two attributes turn the form itself into the tool:

<form
  toolname="set_reorder_point"
  tooldescription="Set the reorder point for one SKU. The person can review and undo."
>
  <label for="sku">SKU</label>
  <input id="sku" name="sku" required />
  <label for="units">Reorder point, in units</label>
  <input id="units" name="units" type="number" min="0" required />
  <button type="submit">Save</button>
</form>

Everything the imperative version spelled out is already in the form. The fields are the schema; a field’s description comes from its label, or from a toolparamdescription attribute when the label is not enough. When an agent submits, your code sees a completely normal submit event, as if the person had clicked Save, with one extra flag: agentInvoked is true. respondWith() on that event plays the role of execute’s return value: it sends the result text back to the agent. Use the function call when your actions are functions, the attributes when your actions are forms. Either way the verb ends up declared, and the label you were already writing for people becomes documentation for agents.

Two ways in, one verb out: register the function your button calls, or annotate the form you already have.

Start with the reversible verbs

The first verbs to declare are the ones that already exist, undo cleanly, and can be watched. On the ordering desk that means setting a reorder point, changing a filter, opening the right form with the fields already filled: actions whose failures stay small and visible.

A tool can still stop and ask. Exposing the action is not the same as letting the agent spend money.

Even the dangerous verb, sending the purchase order, is worth declaring, for a defensive reason: an agent will try it through the screen anyway, and a declared verb at least routes the attempt through code you wrote. That code should stop and ask. Show the confirmation in the interface, and resolve the tool call only after the person decides. The pause is the product keeping the person in charge. The confirmation belongs on the changed row, not in a chat transcript, but that is an argument of its own.

The line where declaring stops also exists, and your product has already drawn it: re-authentication. Everything the session alone can reach is worth declaring, gated where it is irreversible. The actions behind a password prompt or a payment confirmation, deleting the account, changing the card, stay off the surface. The wall stops an agent with or without a verb, so a verb there defends nothing; it only puts the suggestion into the model’s context.

Trust follows the origin

The security model adds nothing new; it reuses the rule the web already has. One website cannot reach into another: that is the origin boundary, and tools live behind it. A tool you register is visible to your own pages and to nobody else’s.

The boundary does not apply to the person’s agent, because the agent is not a website. It is part of the browser, acts with the person’s permissions, and sees what the person’s tab sees. The boundary matters when pages want to share tools with each other. The concrete case is an embedded iframe from another company: its tools stay invisible until both sides agree. You, the embedding page, grant allow="tools" on the frame; the frame, in its registration, lists which origins may see the tool, via exposedTo.

The boundary runs between pages, never between the person and their agent. The agent sees what the tab sees; the iframe's tools stay dark until both sides agree.

Be precise about what that boundary protects: sites from each other, never the person from their own agent. The agent holds the person’s standing on every site at once, so a hostile page can try to use it as a courier, reading on one site and acting on another. Policing that is the browser’s job, not the page’s, but it should shape your list of verbs: the boundary will not save an action you should never have exposed.

There is also a hard off switch. A page that has weakened its own origin, by setting document.domain or by sending the Origin-Agent-Cluster: ?0 header, gets no API at all. When tools refuse to appear, check those two before anything else.

Follow that through and tools behave like permissions, not like widgets. Dropping a vendor’s iframe into your page does not sprinkle the vendor’s tools into it. Nothing is shared until you write the grant into your own markup, the same way a frame only gets the camera when you say so. For software that acts instead of displays, that is the right default.

The description is an attack surface

The origin boundary answers who may see a tool. It says nothing about what the tool tells the agent once seen. Every string you register, the name, the description, the parameter hints, ends up as text in front of a model, and text in front of a model can steer it. A hostile page can bury instructions in its own tool descriptions, aimed at the visiting person’s agent; a compromised frame can try the same. Chrome’s security guidance responds with budgets instead of filters: about 30 characters for a name, 500 for a description, 150 per parameter, roughly 1,500 for a result. A budget cleans nothing. It just limits how much attack fits into one string.

The page’s own strings are not even the main risk. An honest tool can carry someone else’s dishonest text. The desk shows supplier notes for each SKU, so get_supplier_notes returns whatever the supplier typed, and one of those notes may speak to the agent instead of to people. The proposal has two annotations for exactly this:

await document.modelContext.registerTool({
  name: "get_supplier_notes",
  description: "Read the supplier notes for one SKU.",
  inputSchema: {
    type: "object",
    properties: { sku: { type: "string" } },
    required: ["sku"],
  },
  annotations: {
    readOnlyHint: true,
    untrustedContentHint: true,
  },
  async execute({ sku }) {
    return formatNotes(await getSupplierNotes(sku));
  },
});

readOnlyHint tells the agent the call changes nothing, so it may look as often as it likes while planning. untrustedContentHint marks the result as other people’s text: something to show the person, never orders to follow. Both are advice, not enforcement. The enforcement is structural: however thoroughly a note fools the agent, the agent can still only call the verbs you declared, with the inputs you defined. Declared is not harmless, though: a verb that is safe once can be harmful forty times in a row, and a fooled agent zeroing the reorder point of every SKU never leaves the declared surface. The caps you put on your API, rate limits and scopes, belong on tools too. One escape remains. A fooled agent can put down the tools and go back to clicking, and the screen path has no schema to stop it. That is why the purchase order kept its confirmation gate in the interface itself.

Who is on the other end

I have been saying “the agent” as though it were one thing. In practice, today, there are three callers you might actually meet. The headline one is the browser’s own agent: Google has announced Gemini in Chrome as the first consumer, and the browser hands it each page’s tools directly, with no extension in between. Until that ships, the workhorse is an extension called Model Context Tool Inspector, which lists every registered tool and lets you call each one by hand. The third caller is your own JavaScript, because the same surface that registers tools can also list and run them:

const tools = await document.modelContext.getTools();
const tool = tools.find((t) => t.name === "set_reorder_point");

document.modelContext.ontoolchange = () =>
  document.modelContext.getTools().then(refreshPalette);

const result = await document.modelContext.executeTool(
  tool,
  JSON.stringify({ sku: "4711", units: 1240 }),
);

getTools() returns the current list, ontoolchange fires whenever that list changes, and executeTool runs one tool. The arguments travel as a JSON string today; making that a plain object is under discussion in webmcp#243. None of this needs AI: a typed list of every action on the screen is how you build a command palette, and that palette may well be your tool list’s first real user.

Try it this week

WebMCP is not ready for production, and does not need to be. What it offers this week is a cheap experiment. Know who you are building for: the one announced consumer is Gemini in Chrome, unshipped, and no other engine has committed, so the protocol can still lose. The experiment survives that outcome, because the verb list is yours either way.

An afternoon is enough. Pick one reversible verb from your product, register it, and call it from the inspector, the way an agent will. The call will succeed; what matters is what you had to write down to make it callable: the verb’s real name, its real inputs, and what you promise the person about undo.

Or start even smaller, with the demo below. When your browser exposes the API, the demo actually registers the ordering-desk verb; the simulate button calls the same function an agent would, so you can watch the call arrive either way.

Live on this page
  • set_reorder_point · checking…
SKUArticleReorder point
4711Filter coffee 500 g800 units
4712Espresso beans 1 kg350 units
4713Oat drink 1 l1200 units

With the WebMCP origin trial or the chrome://flags/#enable-webmcp-testing flag active, this page registers the tool for real. The simulated call runs the identical function an agent would: no clicking, no guessing, and the person still owns the yes.

The experiment can be checked mechanically, too. Launch a browser test with Chrome’s WebMCP testing flag and assert two things: the tool appears in getTools(), and calling it changes the row. Put that test in CI beside the click-path tests that guard the human interface, and a release that breaks a declared verb fails before it ships. Lighthouse 13.3 adds an experimental Agentic Browsing category as well; it surfaces registered tools, checks form annotations, and validates the accessibility tree.

Whatever happens to the protocol, that exercise leaves something behind: a written list of your product’s verbs, each with a name, a schema and an owner. That list is product design, and it stays valuable under every protocol that could win.

Not every page wants to be called. A business that earns from the person seeing the screen, the ad-funded page, the checkout built around upsells, loses when an agent completes the task without looking, and for it, not declaring is a decision rather than an oversight. But the choice is not between agents and no agents. Pages that can only be clicked will be scraped anyway, on nobody’s terms.

So if you do one thing after reading this, make it the list rather than the code. Sit down with whoever knows the product and write out its verbs: what it can do, what each one needs, which of them undo cleanly, and which must stop and ask. An hour gets you most of it. Then declare the safest one, call it from the inspector, and watch it work. Whether WebMCP is the protocol that wins matters less than whether that list exists, because everything after this depends on it and almost nobody has written it down.

Verified · August 2026
  • OpenAI Operator: announced 23 January 2025. OpenAI’s own limitations: stuck on complex interfaces, password fields and CAPTCHAs; trained to ask the person to take over. The 17 July 2025 update on that page folded Operator into ChatGPT agent; the standalone site shut down on 31 August 2025.
  • Spec: webmachinelearning/webmcp, W3C Web Machine Learning Community Group, first published August 2025. Entry point is document.modelContext; earlier material used navigator.modelContext.
  • Chrome: origin trial since 149; local testing via chrome://flags/#enable-webmcp-testing. The Model Context Tool Inspector extension lists registered tools and calls them by hand.
  • Requirements: origin-isolated documents only; cross-origin iframes need allow="tools" plus exposedTo (passed in registerTool’s options).
  • Annotations in the spec draft: readOnlyHint and untrustedContentHint. The spec’s only hard limit is the tool name, 1 to 128 ASCII characters; the 30/500/150 budgets and the ~1.5K-per-output limit are Chrome’s security guidance.
  • Consumer surface in the spec draft: getTools(), executeTool(tool, jsonString) and an ontoolchange event on document.modelContext; the spec notes this half serves in-page JavaScript agents, while the browser’s own agent consumes tools internally.
  • Lighthouse 13.3 includes an experimental “Agentic Browsing” category: it surfaces registered WebMCP tools, checks form annotations against their schema, validates the accessibility tree, checks for an llms.txt, and reports layout shift.
  • Declarative API: toolname, tooldescription, toolparamdescription and toolautosubmit attributes; agent submissions set SubmitEvent.agentInvoked, and respondWith() returns a result (Chrome guide). These values move; check before you rely on them.