tech/two-doors · 14 min

tech/ · WebMCP

Two doors

Set the tools once, and a task you used to do by hand becomes something you can say out loud.

tech/two-doors -> maina621643 · 21 Sep 2026readable by your agent · get_outline
The claim

01 the claim

Most of the web’s visitors are about to be agents, and the page has to decide how to receive them.

An agent reading a page has to guess what to click. An agent given a tool only has to choose one.

Right now an agent that wants to do something on your site does it the way a stranger would: read the page, guess which control does what, click, read again. Every step is a guess, and a guess can hallucinate a button that is not there. If instead the page hands the agent a short list of things it can do, each with a name and a schema, the guess collapses into a choice. Get the choice right and the action is almost guaranteed.

That is the promise of WebMCP, a page-side API where a site registers tools for whatever agent is driving the browser. The docs tell you how to register one. This post asks a narrower question: on a real task, with a real agent, how much does it matter?

If there is a complicated task I do by hand, I can set the right tools once, and then I can say it out loud and the agent gets it done.the sentence this post has to earn

02 play with it

Pick the eleven for Saturday.

A small app with fiddly rules, the kind a club analyst does by hand every week. 27 players. A formation to fill. Injuries, suspensions, minutes load, a European registration list that differs from the league one. And one commitment at the end: submit the team sheet.

Try it by hand first. Then pick a door, press run, and watch what the agent does with what it can see.

Manager XI league · matchday 9 · 4-3-3
Name a legal XI in a 4-3-3 for the league match and submit.

nextPick a player from the squad, then tap the slot to put him in

Goalkeepers

Defenders

Midfielders

Forwards

7 of 11 named · bench 1 · captain Pedri

Door A is selected: no tools registered, the agent can only read the page. Press run to watch a recorded one, or play it yourself.

Names, numbers, positions and nationalities are the real 2026-27 first team. Everything about availability — fitness, suspensions, minutes, registration lists and the club-trained flag — is made up by me for this app. None of it is on this page, and neither is nationality: they live in another system, which the tools can ask and the form only reports at submit.

no tools registered · the agent reads the page
The app is client-side only, records every run as a trace, and grades it. Real names, fictional availability.no real data

03 two doors

Same task, same agent, one switch.

Every run goes through one of two doors. Through Door A no tools are registered; the agent reads and clicks like a person. Through Door B the page has registered 11 tools, and the same agent gets the list. The page records what happened either way: every click, every call, every rule the app refused.

The hard task, both doors: the two recorded runs the Run button plays, mark for mark. A tick is a click or a call, a cross is a submit or a call the rules refused, a filled node is a sheet that went in, a ring is a run that never finished.

These runs were made three times, on three versions of the app, and the numbers below are the third. The first page printed the rulebook and the compliance counters beside the pitch, and every DOM run met its task with nothing refused: 9 of 9, at a median of 17 clicks against 13 calls. The second put the rules in a legalese drawer and left a fitness chip and the minutes on every squad row, and the DOM door still met 9 of 9 at 20 clicks, because nothing it needed was off the page. So the third page hides what real apps hide: the squad list is a name and a position, the medical report is a tool and a refusal at submit, and nationality and the club-trained mark live in the tools too. Same agent, same harness, same tasks.

Door A · clicks
28median
Door B · calls
17median
Door A · task met
6of 9 runs
Door B · task met
9of 9 runs

Claude Opus 5, a fresh agent per run ran it, on Playwright-driven Chromium with document.modelContext from the page's WebMCP polyfill, on a local build of 89733f5, 3 runs per task per door, 1 run excluded: medium-B: three attempts before run 14 were killed by the API (one HTTP 529 after five calls, then a 500 and a 529 before any call); none was the agent's doing; set aside under tmp/aborted, not in this folder, not counted.. Run 17 of rules-in-drawer (hard, Door B): a person pressed Confirm after the runner's own permission system stopped it at the human step; its calls stand. The figures draw the as-shipped condition; 18 earlier runs as rules-in-view and 19 earlier runs as rules-in-drawer are kept and reported in 04.

04 the score

Where the DOM door gives out.

Three tasks, rising in rule density: a legal eleven for Saturday, the same with the tired men rested and the keeper as captain, and Wednesday’s European match with its own list, a homegrown minimum, a keeper on the bench and nobody doubtful. The numbers below are minutes and wrong sheets, not clicks.

Medium through the DOM: 0 of 3 met. 2 runs confirmed a sheet the page accepted with four over-cap starters on it, and the third declined because it could not know. Through the tools, 3 of 3.

The tool door made no wrong action in nine runs (0, 0 and 0 refused), because the report is one call and every runner made it first.

From the first recorded action to the confirm or the decline, thinking included, the same model and harness on both doors: 1.4, 4.3 and 3.6 minutes through the DOM against 1.1, 1.8 and 1.9 through the tools. The difference is the refusal loop, submit, be told, fix, and that is what searching the DOM costs against asking a tool.

A team sheet is a small thing to get wrong. The failure is not. A DOM agent that submits a wrong sheet and is never told is the same agent that will confirm a wrong order, a wrong transfer or a wrong booking: whatever the page accepts without objecting. The page accepted it, so the agent reported success, and nobody was told. An agent acting on someone’s behalf has to act on reliable information, and a control on a page is not information about the rules behind it. Well-defined tools are: the rules as data before the first move, the availability report as one call, a refusal that names the article. That is what WebMCP registers on the page, and it is why the tool door was right 9 times in 9 with nothing refused, in less time on every task.the point of the post

That is how agentic browsing should work: a site that registers its tools is telling an agent what it can rely on, and the agent that asks before it acts is the one you would let act for you. This post cannot say more than its runs do, nine through each door, three per task. On those runs the tool door made no wrong action and the DOM door made 2 silent ones.

Even where the DOM door reached a legal sheet it took a refusal to get there: a median of 1 per run on easy, 2 on medium, 2 on hard. The agent only learns at submit, and only if the app refuses.

The harder the task, the wider the gap.

Rule density is a count, not a feeling: how many of the nine articles can be got wrong in a task, worked out from the rulebook and the board the task hands you rather than typed under the axis. Easy binds 3 of them, medium 6, hard 7. Plotted against it, the two doors do different things.

Door A · the DOMDoor B · the tools
the same numbers as a table
TaskRulesDoor A metpage-legalrefusedclicksminutesDoor B metpage-legalrefusedcallsminutes
Easy · a legal XI33 / 33 / 31151.43 / 33 / 30111.1
Medium · rest, left-back, captain60 / 32 / 32284.33 / 33 / 30181.8
Hard · the European list73 / 33 / 32343.63 / 33 / 30171.9
Median minutes per run, first recorded action to the confirm or the decline, the agent’s thinking between steps included, the same model and harness on both doors; and the median number of submits or calls the rules refused on the way. Clicks and calls are in the table: a click through Door A is a click, a key or a typed value the agent issued; a call through Door B is one tool call.

05 the tools

Three tiers, and a human at the last one.

Eleven tools sounds like a lot. They sort into three kinds, and the kind decides how the agent may use them. This is the same sorting the public WebMCP directory uses for eight hundred sites, so the reader learns it from the build rather than from a glossary.

Answerread-only · readOnlyHint

  • get_squad
  • get_availability
  • get_rules
  • get_team_sheet
  • validate_team_sheet

The agent may call these freely. Nothing changes. validate is the one that makes the hard task possible: the rules come back as a list, before submit.

Actionreversible · drives the page

  • set_formation
  • place_player
  • bench_player
  • set_captain
  • clear_team_sheet

Each one moves the sheet and can be undone by another. Small schemas, one verb each. A giant edit_sheet would have been one tool and much worse.

Sensitivecommitment · consequentialHint

  • submit_team_sheet

One tool, and its execute does not resolve until a person confirms in the page. The agent proposes; the human commits. That line is what lets a site accept agentic traffic at all.

And a small experiment on the side: the same tools with bad names, one giant schema, and no annotations. Same agent, same tasks. The score drops, and by how much is the part the docs cannot tell you.

06 how it is wired

Five parts, no server.

Everything runs in the reader’s browser. The trace is a JSON file the human downloads; there is nothing to upload to and no key anywhere. The post’s figures are drawn from those files.

WebMCP, in four moves.

The whole of Door B is four steps, and none of them need a server. A page declares what it can do, the browser holds that list for the tab, and the agent asks the list rather than the pixels. Everything below is the API as it stands in the draft I built against; it is moving, and 07 says where.

  1. 01Create

    A tool is four things: a name the agent matches on, a description the model actually reads and reasons about, a JSON Schema for the input, and an execute that runs in the page with the page’s own state and the user’s own session. No endpoint, no key. The description is the part people underwrite, and it is the part doing the work.

    tools.ts · one of eleven
    const placePlayer = {
      name: "place_player",
      // the model reads this. Write it for a reader, not for a linter.
      description: "Put a squad player into an open slot of the current formation.",
      inputSchema: {
        type: "object",
        properties: {
          player: { type: "string", description: "Squad player's surname." },
          slot:   { type: "string", enum: SLOTS },
        },
        required: ["player", "slot"],
      },
      // hints let a client sort read-only from consequential
      annotations: { readOnlyHint: false },
      // every execute is wrapped, so the recorder sees the call and the diff
      execute: async ({ player, slot }) => record("place_player", sheet.place(player, slot)),
    };
  2. 02Register

    Feature-detect before you touch it. The API moved from navigator to document this year, and in stable Chrome it sits behind a flag or an origin trial, so a page that assumes it exists breaks for every reader who does not have it. The September draft has no unregisterTool: you pass an AbortSignal to registerTool, and aborting it is how a tool goes away, which is exactly what you want on a route change. Chrome’s early preview and the polyfill still carry older names, navigator.modelContext among them, so write to the draft and detect.

    registered from the root layout, so it exists on every route
    if (document.modelContext) {
      const teardown = new AbortController();
    
      await document.modelContext.registerTool(placePlayer, { signal: teardown.signal });
    
      // no unregisterTool. The signal is the lifetime.
      onRouteChange(() => teardown.abort());
    }
  3. 03Discover

    This is the first thing the agent does, and the reason Door B is short: instead of reading a page and guessing which control does what, it asks for the list and gets names, descriptions and schemas. You can call it yourself from the console, which is the fastest way to prove your catalog is really registered rather than merely written. This page switched the panel to Door B when you got here, so the eleven are registered; through Door A the list below is the site’s own tools.

    what the agent asks first
    const tools = await document.modelContext.getTools();
    
    tools.map(t => t.name);
    // 25 tools: the site's 14 (list_pages, search, list_posts, …)
    // and the app's 11: ["get_squad", "get_availability", "get_rules", "get_team_sheet", …]
  4. 04Call

    The call is the tool you found in the list plus an object that satisfies its schema. What comes back is a string: whatever your execute returned, serialised. Parse it and it is structured, so the agent does not have to scrape the page to find out whether it worked. A refusal is a returned error, not a silent no-op, which is the difference that makes the whole run recoverable. The sensitive tool is the one exception: submit_team_sheet stops and asks a person, and that confirmation lives in the page.

    one call
    const tools = await document.modelContext.getTools();
    const place = tools.find(t => t.name === "place_player");
    
    const raw = await document.modelContext.executeTool(place, {
      player: "Gordon", slot: "LW",
    });
    const result = JSON.parse(raw);   // executeTool resolves to a string
    
    // { ok: true, slot: "LW", sheet: { filled: 9, open: 2 } }
    // and on a rule violation, an error the agent can read and act on:
    // { ok: false, error: "not_on_european_list", player: "Raphinha" }

To try it today you either run the polyfill, @mcp-b/webmcp-polyfill, or turn on chrome://flags/#enable-webmcp-testing. Tools are bound to the tab: close it and the catalog is gone, which is the property that makes this safe to put on a personal site.

Key snippets only. The rest lives in the app.

07 where it breaks

What this does not prove.

01WebMCP is an early preview behind a browser flag. The API moved from navigator to document this year and shed provideContext on the way. Expect it to move again.
02Eighteen runs on this page, three per task per door, is a small number. Agents are not deterministic, so the post reports the spread and shows every trace, not one lucky run. And the medium finding is narrower than it looks: it is about a rule the app only enforces in Europe and minutes that live off the page. An app that refused tired men on a league night would have taught the DOM agent at submit, the way the suspension did.
03An agent can ignore the tools and click anyway. Door B measures what happens when it takes them; it cannot make it.
04Anyone who can get words onto the page can try to give the agent orders. Every tool comes with a sentence saying what it does, and the agent reads those sentences and follows them. I write those. But the agent reads everything else too: the page, and whatever a tool returns. It cannot tell the difference between a sentence I wrote and one a stranger left in a review, a username or an advert that ends up in a result. They are both just words. There is no filter for this, and I am not claiming one. The draft does give a tool a way to say so: an untrustedContentHint annotation that marks a result as carrying text from someone other than the site. A label, like the one in 09, not a filter.

What the three kinds do is limit the damage rather than stop the trick. The first kind only looks things up, so it can change nothing. Everything in the second kind can be undone by something else in the second kind. And the only tool that actually commits anything waits for a human. So the worst a hijacked agent can do here is make a mess you can tidy up. It cannot send the team sheet.

That is only true while I sort the tools honestly. If I had quietly put submit_team_sheet in the second kind, none of it would hold.

08 say it out loud

“Pick the eleven for Wednesday.”

The last run is the one the post was for. The hard task, said out loud to the agent in the browser, with Door B open. It asked for the rules, asked who was available, filled the sheet, validated it, and stopped at submit to ask me. I said yes.

09 for your site

Tools that look things up. Then tools that can be undone. Then the one that waits for you.

That order is the whole recipe, and you can stop after the first part and still have shipped something useful. Tools that only look things up cannot break anything. They are safe to put live today, and they are most of what an agent actually needs. Then add the ones that change the page, keeping each to a single job so that another tool can always put it back.

The last one is the one worth being careful about. Some things should not happen unless a person says so: spending money, sending a message in your name, submitting the team sheet. So that tool is built not to finish on its own. When the agent asks for it, the request appears on your site as something you have to press. Until you press it, nothing has happened. The agent can propose. Only you can commit.

Two things decide whether that is real protection or just for show.

The question has to appear on your page, not in the agent’s own chat window. If the agent is the one being asked, the agent can answer, and a permission something gives itself is not permission.

And there is a label for these tools, consequentialHint, which is only a warning. It tells the agent’s software that this one is serious. It does not stop it. A polite agent slows down and a rude one carries on, so the actual stop has to be code on your own site. The label announces the lock. It is not the lock.

This post is a page with tools.

Ask your agent to read it and it will find three tools on this route: the outline with the claim each section makes, an explanation of any section, and the evidence behind any number, pointing at the trace it came from. A plain-text version lives at a stable URL for agents without a browser.

get_outlineexplain_sectionget_evidencetwo-doors.txt
> get_outline() { "post": "two-doors", "claim": "tools turn a guess into a choice", "sections": [ { "id": "doors", "claims": "The only difference between the two runs is whether tools were registered.", "evidence": [ "fig/traces", "value/door_a.clicks_median", "value/door_b.calls_median", "value/door_a.legal_runs", "value/door_b.legal_runs" ] }, { "id": "score", "claims": "The DOM door fails where a rule lives somewhere other than the control that breaks it.", "evidence": [ "fig/bars", "value/legal.easy.a", "value/legal.easy.b", "value/legal.medium.a", "value/legal.medium.b", "value/legal.hard.a", "value/legal.hard.b", "fig/curve", "value/curve.easy.a.cost", "value/curve.easy.b.cost", "value/curve.medium.a.cost", "value/curve.medium.b.cost", "value/curve.hard.a.cost", "value/curve.hard.b.cost" ] } ] }
merged to main from tech/two-doors · the next post on this branch is about the pit wall← the blog