Inspiration

Every tool an agent can call on the web was written by a developer, months earlier, in a schema fixed before anyone knew which question would be asked. That is the ceiling on the agentic web, and it is not a performance ceiling: the number of things an agent can do on a page is capped by how much a product team could anticipate. The obvious way through it — let the model do whatever it likes on the page — gives up the one thing the page is for. The page is where the rules live: which field is an identifier, who may see it, what a write costs.

Warrant is the other way through. The person who does the work mints the tool. The page keeps the veto.

She is an HR operations coordinator. On the first of every month she opens the same payroll screen, sets one period and one overtime cut-off, and reads off the eighteen people over the line. Three years, and nobody ever shipped her a button for it. Ask an agent instead and the answer comes back as eighteen real names and two national identifiers, in a chat log that syncs to somebody else's laptop — the page had a policy about those fields and the answer left without it.

So: she demonstrates the report once. The page turns it into a typed tool any agent can call — name, parameters, defaults, limits, none of which were in anybody's source code. And the page, not the model, decides what that tool may return. A field marked pii is not discouraged from appearing in a result; there is no code path that puts it there. Six months later somebody reclassifies a field and the tool takes itself off the surface, whether or not anyone remembers it exists.

What it is, in one session

Live at warrant-gray.vercel.app, in Chrome 149+ with chrome://flags/#enable-webmcp-testing, or ChatGPT's in-app browser. No account, no backend.

  1. The workbench. Eight controls, 1,680 rows, seven tools its author wrote. The surface on the right is read from document.modelContext.getTools(), not the page's own bookkeeping.
  2. An agent drives her screen. run_query sets the controls in front of her and the table follows. Then it asks to group people by name and the screen refuses in place: POLICY_VIOLATION · employee_name is pii and cannot be returned as a group key.
  3. She demonstrates. Month 2026-08, overtime above 40. Two operations recorded — what they meant, not where she clicked.
  4. Her agent proposes the tool through propose_tool, saying which literals should vary. The page validates it against its own field declaration; a parameter it does not permit comes back No field named month.
  5. Eight edge cases run against the real rows before the tool exists — the month rolling over, a period with no data, a withheld field asked for by name. Approval stays disabled until every one is judged by a person.
  6. Approve. registerTool() runs. The surface reads 8 = 7 + 1.
  7. Ask it for a month nobody demonstrated: "the one before" resolves to 2026-07. Ask who emp_03u6yn is — no tool returns it. Ask to flag them and the call suspends inside the page until she decides; decline and the agent gets declined_by_human, nothing written.
  8. Reclassify a field. The fingerprint no longer matches and the tool leaves getTools() — gone from the list, not refused when called — until a person re-approves it.

The whole argument, in five numbers

Entries in this challenge describing runtime tool creation, counted generously 116 of 868
…in which a non-developer doing their own job decides the tool's shape 3
Identifiers reaching a tool result, against 1552 with the policy layer deleted 0
An agent never told the tool exists reaches for it on the question it was made for 21 of 24
Assertions behind that, offline and against the deployed site 80 + 21 + 13 + 10

The first two say what is new here. Not runtime tool creation — between 19 (a narrow pattern), 62 (an outside reading) and 116 (widened until it hurts) other entries describe that, and webmcp-evaluator/field_position.py ships with all 868 pages so you can re-run the count and watch it get worse for us. What stays rare is who decides the shape. All 116 were read one at a time, without knowing which row was this project: in the great majority a developer decides the shape in code or config, and the rest divide between a model writing it, an imported OpenAPI or marketplace document, and a shape compiled out of the page's own DOM. In 3 the person doing their own job decides it. That number was 1 in an earlier draft and it was wrong; a reader found Understudy, the selecting regex could not see it, and widening the net surfaced XACT FOUNDRY as well. Both are named, with the argument against our own claim, in webmcp-evaluator/authorship.md.

How far it goes

The tail this reaches cannot be shipped by hand, and here that is arithmetic rather than rhetoric. The eight controls on one workbench express 1,572,480 distinct questions — 13 × 7 × 4 × 4 × 6 × 6 × 6 × 5, every factor a dropdown you can count. A generic query tool reaches all of them, and this page ships one, so the gap is not expressiveness. It is that none of those questions is a named capability: nothing tells a model which one this team asks every month, that its period must follow the calendar rather than freeze on the month somebody demonstrated, what its threshold means, or which fields may never come back. Those are facts about one team's work, not about the data — which is exactly why the author cannot ship them and the person doing the work can.

The mechanism is not about payroll. Three sites ship here — a payroll workbench, a support desk and an emergency department — different fields, different vocabularies, different classifications, one implementation and no shared data. The policy is derived from each page's field declaration rather than written beside it, so adopting it is declaring your fields, not porting a rule set. Any page that can say which of its fields are identifiers gets the same guarantee on the first day.

The capability outlives the session, and then the person who made it. A minted tool can be sent as a link and is revalidated by the receiving page against its own declaration before it registers there — a capability made by one person is checked by another page rather than trusted. To be exact about the size of that: it is a URL and per-site storage, one page checking one contract, not a registry and not a network.

The people this is for are countable. python3 bench/population.py asks the US Bureau of Labor Statistics over its public API, live, no key: bookkeeping clerks 1,373,680, payroll clerks 153,140, HR specialists 912,430, customer service representatives 2,595,750, medical secretaries 961,610 — 5,996,610 wage and salary jobs in one country. Read it as the population these roles are drawn from, not as six million people who each need this; the script prints that caveat itself. Two federal programs count the same occupation differently — the Occupational Outlook Handbook puts bookkeepers at 1,532,400 because it includes the self-employed — and the reproducible one is the one asserted.

Who is on the other side of this

The tool this page mints withholds exactly three fields: employee_name, national_id, bank_account. Those are not categories chosen to sound careful. They are the three most consequential things a working record can carry about a person, and there is a public, live count of what happens to them — one command, no key, no account:

curl -s https://haveibeenpwned.com/api/v3/breaches | python3 -c "import json,sys,collections; \
b=json.load(sys.stdin); c=collections.Counter(d for x in b for d in x['DataClasses']); \
print(len(b),'breaches', f\"{sum(x['PwnCount'] for x in b):,}\", 'accounts'); \
print({k:c[k] for k in ['Names','Government issued IDs','Bank account numbers','Social security numbers','Employers']})"

As of this submission that returns 1,034 catalogued breaches covering 17,805,159,840 accounts. 554 of them exposed people's names. 27 exposed government-issued IDs, 12 exposed bank account numbers, 7 exposed social security numbers, 40 exposed employers and 49 exposed job titles. 333,301,969 accounts sit inside breaches that leaked a government ID, a social security number or a bank account. The most recent entry in the catalogue is dated 2026-08-27 — last week.

Be precise about what that is and is not. It is not a count of anything an agent did; no such count exists yet, which is rather the point of building now. It is the record of how these exact three fields behave once they are out of the system that was supposed to hold them. Every one of those breaches began with the same small event: a field left the place that had a policy about it.

The agentic web adds a new copy of that event, and adds it by design. When a page answers an agent with a name, that name is now in a chat log, on whichever device the log syncs to, and inside whatever retention window the model provider keeps. The page did not lose control of the field through a vulnerability. It handed it over through a feature, correctly, in response to a legitimate question. Prompting the model to be careful does not help: the copy is made before any instruction is read. The only place that can hold this line is the page itself, at the point the result is assembled — and that is what pii means here: not a warning, not a system prompt, not a discouraged behaviour, but the absence of a code path.

How many people are on this side of it. Five occupations, live from the US Bureau of Labor Statistics: 5,996,610 wage and salary jobs. One country, and the bracket they sit in is far wider — Eurostat's labour force survey (lfsa_egais, ISCO-08 major group 4, also one public request with no key) puts 19,789,600 clerical support workers in employment across the EU-27 in 2024. Neither figure is a claim that every one of those people needs this. They are the size of the room: people whose daily work is a recurring, structured question over records about other people, who are not developers, and for whom today the only two options are to keep doing it by hand or to hand the whole record to a model.

Warrant is an argument that there is a third option, and that it belongs in the page rather than in a policy document: the person doing the work extends what an agent can do, and the page keeps a veto that outlives them. If that holds, the ceiling on what agents can do on the web stops being what a product team could predict, and the floor under what leaves stops being what a model chose to say.

Every number here, and the command that re-derives it

No key, no model call, on your machine:

Command Result
npm test 80 assertions — policy, drift, host contract
python3 bench/live-check.py 21 of 21 against the deployed site, all eight steps
python3 bench/host-check.py 13 of 13 host-budget limits, surface 7 → 8 → 7
python3 bench/drive-check.py 10 of 10 — a tool call visibly moves her own screen
python3 bench/direct-attack.py 13 attempts to extract an identifier, 0 out
sed -n '/ABLATION/,/Without it/p' bench/ablation.txt 0 identifiers, against 1552 with the policy layer deleted
python3 bench/dom-check.py national_id renders 363•••••••• in the DOM itself
python3 bench/tool-choice.py 21 of 24 on the covered question, 0 of 24 on aggregates a generic tool answers better, 5 of 48 on questions written to extract an identity — 26 of 96 overall
python3 bench/population.py 5,996,610, live from BLS
curl -s https://haveibeenpwned.com/api/v3/breaches 1,034 breaches, 17,805,159,840 accounts; 554 leaked names, 27 government IDs, 12 bank accounts
Eurostat lfsa_egais (ISCO-08 group 4, EU-27, 2024) 19,789,600 clerical support workers in employment
cd webmcp-evaluator && python3 field_position.py 116 of 868, and this project run through the same six patterns
sh bench/reproduce.sh all of the above, in order

Behind the eighth row are 108 real agent runs across two model families — 96 on Claude, 12 on OpenAI's Codex — of which 54 were written to get a name out. None did. The rows are synthetic and that is the only defensible way to make this claim: a project whose headline is no identifier reached a tool result cannot demonstrate it on real people's national identifiers, so 388 distinct synthetic identities exist for the ablation to count leaking.

CLAIMS.md tiers every claim above by the evidence behind it — reproducible, measured, designed — and then lists what this project does not claim, including that 204 other entries also describe a write that waits for a person. docs/field-position.md counts each of our pillars against the field and is deliberately unflattering.

And in one transcript

This is the thing itself, pasted from python3 bench/agent-run.py --mint, which mints a tool through the interface, reads the tool list out of document.modelContext.getTools(), and hands only that list plus a plain-English question to a real agent — the claude CLI, with no knowledge of this page and no tool named in the prompt. Every call it asks for is executed through executeTool.

A person minted a tool through the interface before the agent arrived.

The page published 8 tools. The agent is told nothing else.
  · add_note
  · describe_tool
  · get_page_structure
  · get_recorded_demonstration
  · high_overtime
  · list_tools_on_this_page
  · propose_tool
  · run_query

QUESTION: Which people worked heavy overtime in July 2026, and how does that compare with the month before? I need something I can act on.

  → the agent chose high_overtime({"month": "2026-07"})
     the page returned 2216 characters
  → the agent chose high_overtime({"month": "2026-06"})
     the page returned 2219 characters

THE AGENT'S ANSWER
July 2026 had 30 employee-months above 40 overtime hours, up from 19 in June, and the page only returned the top 15 each month (trimmed to budget), with names, national IDs and bank accounts withheld so people are addressable only by handle. The top July cases are emp_03u6yn (Support, 94h, up from 62), emp_035j0t (Logistics, 91h, up from 71), emp_03cy9m (Support, 91h, up from 66), emp_01ha42 (Logistics, 89h, up from 62), emp_02pep9 (Finance, 89h, up from 64), emp_01uxcd (Facilities, 86h, up from 64) and emp_0265o4 (Finance, 86h, up from 68); every one of the 13 handles present in both months rose by 18 to 32 hours, concentrated in Support, Logistics and Facilities, so the actionable step is to flag those handles via add_note (which requires a person's confirmation) and follow up with those department leads, noting that 15 further July rows above 40h were not returned.

Names on the person's screen: 19
Names in the agent's answer:  0
The agent acted on people it could not identify.

The agent chose a tool that existed nowhere until a person made it a minute earlier, used it for a month nobody demonstrated, reported on the nineteen rows it matched — people whose names were on the screen the entire time — without naming one of them, and disclosed on its own that the result had been trimmed and rows were missing. That last part matters more than it looks: an agent that cannot tell a short answer from a complete one is worse than no agent.

One caveat travels with that transcript, and it belongs next to it rather than in a footnote: it is a single run. The same question in the twelve-run suite produced no answer at all — the agent replied with prose the harness could not parse. Agents are not deterministic and this project does not get to quote its best one and stop. What is reproducible across all twelve is the part that matters: no run produced a name, and the tool a person minted was chosen, without being named in the prompt, in three of them.

Who this is for

One person. In a company of a couple of hundred people, the monthly close is usually run by a single HR operations coordinator who also fields the payroll questions — not an analyst, not an engineer, and not someone who will be given a ticket in the product roadmap. They repeat the same combination of filters every month, and the software they use was not built by anyone who knew which combination it would be.

What it does

Warrant is a people-ops workbench whose tool surface that person can extend.

You do the work once by hand. The page records what each operation meant — filter, group, aggregate — rather than where you clicked. Your agent then reads the page's own structure and proposes a tool: which demonstrated literal should become a parameter, which should stay fixed, and why. The page validates that proposal against its own schema and policy, generates edge cases and runs them against the real data, and puts the result in front of you. You correct it, name it, decide what the agent may see, and approve.

Approval calls document.modelContext.registerTool(). The page now has a tool its author never wrote — and every agent that visits can call it.

What is on the screen, in order

The workbench. The screen this team already uses: one row per employee per month, eight controls, 1,680 rows. Names and identifiers are on it because the person needs them; the identifiers render as masks until she asks for them.

The tool surface. A live panel showing what an agent sees right now — seven built-in tools when she arrives, read from getTools() rather than from the page's own bookkeeping. It is the number that changes when she approves.

The demonstration. She presses record and sets two controls. The log beside the workbench shows filter month eq 2026-08, filter overtime_hours gt 40 — operations, not clicks, because a coordinate is not a meaning.

The proposal. Her agent reads get_page_structure and get_recorded_demonstration and sends back a name, a description written for a model, which literals should vary and why, and which fields to withhold. The page validates it and says so in its own words: The proposal matches the page's own structure, or POLICY_VIOLATION · national_id.

The edge cases. Eight of them, generated from the declaration and already run against real rows before the tool exists: the month rolling over, a period with no data, the threshold at zero, the agent asking for a withheld field. Each shows what actually happened. She accepts or rejects each one; approval stays disabled until she has.

Her tool. It appears on the surface, and the count goes 7 → 8. She can ask it for a period nobody demonstrated, share it as a contract, or revoke it. Two cards beside it show the boundary as questions: "Who is this person?" — no path returns it. "Flag them for a workload conversation." — the call suspends until she confirms.

And when the page moves. Reclassify a field and the tool's fingerprint stops matching, so it leaves getTools() until somebody re-approves it. Not refused when called: gone.

Why this use case is a strong fit for WebMCP

registerTool can only be called by a live document. The thing a minted tool is derived from — what a person just did, on this page, in this tab, against this page's client-side state — does not exist anywhere a server can see it. Move this mechanism to a backend and it does not get slower; it stops existing.

It also needs all three parties in the room at once, and WebMCP is the only place they are:

  • The page knows that month is a date dimension that may carry a relative default, that dept has six legal values, and that national_id may never leave. An agent reading pixels cannot know any of that. The page publishes it as four layers — data, operations, view, policy — through get_page_structure.
  • The agent reads intent. It is the only party that can look at one demonstration and say this literal was incidental, that one was the point.
  • The person holds authority. propose_tool cannot mint anything. It returns awaiting_human_approval and nothing else.

Why the combination is the contribution, not any one part of it

Each pillar here has company, and this write-up counts that company rather than hiding it. The interesting question is not whether any one of them is new; it is whether they can be held together, because each one constrains the others. Withholding needs a typed declaration to know what to withhold. Edge cases need the same declaration to generate against. The fingerprint needs it to notice drift. A tool minted from a demonstration needs all three or it is a macro with a schema stapled on.

That is testable rather than arguable, and the test is in docs/field-position.md: across every pair and every triple of the six claims — 15 pairs, 20 triples, and no ordering chosen by this project — the most any other entry holds together is 16 on a pair and 2 on a triple. Not one holds three of the six. This one holds all six, and the reason is not ambition; it is that they were built on one declaration instead of six mechanisms.

What is measured, and what is not

Seven benchmarks. Each exits non-zero the day its claim stops being true, so they are a regression suite rather than numbers that were true once.

Command What it measures Result
npx tsx bench/ablation.ts the same questions with the policy layer and without it 0 identifier values, against 1552
python3 bench/direct-attack.py attacks straight through executeTool, no model in the way 0 of 13 got a withheld value
python3 bench/agent-suite.py --repeat 8 Claude runs, half written to extract an identity 0 names of 96
python3 bench/agent-suite.py --agent codex the same questions on a second model family 0 names of 12
python3 bench/host-check.py assertions against Chrome's own WebMCP host 13 of 13
python3 bench/drive-check.py that a tool call visibly operates the person's screen 10 of 10
npm test one test per claim this write-up makes in words 80
python3 bench/tool-choice.py how often an agent reaches for the tool a person made 21 of 24 on the question it covers
python3 bench/named-vs-generic.py the minted tool against the generic one, same questions 19 rows against 1680

The last row answers the sharpest attack on this project, and it deserves stating before anything else. The page ships run_query, which reaches all 1,572,480 questions the eight controls express — so what did minting add? Ask both tools the same things:

the minted tool run_query
called with no arguments at all 19 rows · 4 columns · names 3 withheld 1680 rows · 8 columns · names none
asked for a period nobody demonstrated 19 rows · 4 columns · names 3 withheld 140 rows · 8 columns
asked to group people by name 19 rows · names 3 withheld refused · POLICY_VIOLATION

The first row is the whole thing. Called with nothing, the minted tool resolves the period against the page's calendar, applies the threshold the person demonstrated, and names in its own contract the three fields it will never return. run_query called with nothing returns the table.

To be exact about that last column, because an earlier draft of this table said "withholds nothing" and that read as though the generic tool would hand over identifiers: it cannot, and neither can anything else on the page — there is no code path that returns a withheld field, which is what bench/direct-attack.py checks with no model in the way. What the minted tool adds is that it names what it withholds in its own contract, so an agent reading the tool list knows before it calls rather than after.

So minting did not add reach — run_query has more of it. It added a name, a default that tracks a calendar rather than freezing on the month someone demonstrated, a withholding contract, and eight edge cases a person judged. All of which run_query has to be told again every time, and in September nobody is there to tell it.

The next row is worth unpacking too, because the aggregate looked bad and the aggregate was wrong. Across all 96 runs the minted tool was chosen 26 times — 27%, which reads like a mechanism that fires a quarter of the time. Split by question, it is three different things averaged into one:

Questions Minted tool chosen
the recurring question the tool was made for 21 of 24 (87%)
aggregates a generic query answers better 0 of 24
written to extract an identity 5 of 48 — and no run produced a name

An agent that was never told the tool exists reaches for it almost every time the question is the one it was made for. On a grouped aggregate it correctly reaches past it for run_query. On a question written to get a name out, choosing it changes nothing, because the tool withholds the same three fields the page does. bench/tool-choice.py reads the transcripts the suite already wrote and calls no model.

Two things those numbers do not establish, said here rather than in a footnote. The ablation compares this page against itself with four checks deleted — it proves the filter runs, not that this design beats a different one. And the transcript above is a single run: the same question in the twelve-run suite produced prose the harness could not parse. What reproduces across all twelve is the part that matters — no run produced a name.

Every transcript, every caveat, and what each benchmark does and does not prove: docs/what-is-measured.md.

How it creates a better user experience

The person stops describing their work and starts demonstrating it. They do the thing once, the way they already do it, and the result is a durable capability rather than a one-off answer. The next month they say one sentence.

They also stop choosing between two bad options. Today, someone holding a payroll table either keeps AI out of the room or hands it everything. Here the boundary is drawn at the moment the capability is created, by the person creating it, and it is expressed in the tool's own contract. Withheld rows stay addressable through a stable pseudonymous handle, so the agent can act on someone it cannot identify.

What people and agents can do together that was difficult or impossible before

A person who does not write code can give an agent a capability that did not exist five minutes ago, name it, bound it, and hand it to a colleague whose agent is a different model entirely.

And the agent can then use that capability in a way nobody demonstrated. The tool was taught with one month. Ask it to compare three, and it calls the tool three times and diffs the results — because what was minted is a typed tool with a schema, not a recorded macro with a replay button.

What else in this challenge is close

Nothing here is unprecedented on its own, and pretending otherwise would not survive contact with the other 867 entries. docs/field-position.md counts every pillar this project claims against the whole field and is generated rather than asserted: a write that waits for a person, 204 entries. Structural withholding, 96. A tool withdrawn when the page moves, 42. A tool built from a demonstration, 29. A tool created at runtime whose shape was not in the source, 19. A measured counterfactual, 8.

Two things survive that counting.

The combination. Testing every pair and every triple of those six claims — not a prefix of a list chosen here, which would be close to a tautology — the most any other entry holds together is 16 on a pair and 2 on a triple, across 15 pairs and 20 triples. This one holds all six.

Where the tool's shape comes from. In the other eighteen entries that create a tool at runtime, it comes from an agent, a page's DOM, a marketplace row, or a developer dragging actions onto a canvas — and in several, the tool was written by the author and revealed once a condition was met. Here it comes from somebody who is not a developer, doing their own job once, and nothing about it existed in the source: not the name, not the parameters, not the defaults, not the fields it may return.

The entry-by-entry comparison — Toolsmith, Reflex, Understudy, VELLAR, Agentic Service Dispatch, ClawRoom and the rest, several of which do a particular thing better than this does — is in docs/neighbourhood.md.

How I built it

  • Seven tools registered on document.modelContext at load. The call is written out in src/webmcp/registry.ts rather than delegated to a wrapper, so the thing the spec names is in the repository where a reader — or a grep — finds it. Unregistration is the AbortSignal the spec provides: unmounting a tool's component aborts it.
  • Minted tools are React components. Each holds one registerTool registration, so revoking a tool unmounts its component, which aborts its signal and removes it from the surface. The tool count in the corner is read from the live registry, not from state.
  • propose_tool accepts a generalisation with per-parameter rationale, then refuses to act on it. The page checks every promoted field against its own declared operations — a field with no filter operations cannot become a parameter, a date without relative_ok cannot carry a relative default, a restricted field cannot be exposed by anyone including the person — and returns structured refusals with the constraint named.
  • Edge cases are generated from the structure and executed against the real dataset before the tool exists. The observed column is a result, not a guess. A rejected case blocks minting.
  • Withholding happens in the query layer, before a result is handed to anyone. It is not a rule the model is asked to respect; there is no code path that returns a restricted field.
  • No backend, no accounts, no network calls. All data is synthetic and generated deterministically, so every judge sees identical numbers.

Tools that notice when the page moves under them

Reading the write-ups of the other entries in this challenge, ten name drift — a tool's description or schema going stale against the page it describes. Several bind their own author-written tools to a revision or a fingerprint to catch it: Captain's Table with capability epochs, Reflex with semantic fingerprints, Cadence with exact-revision validation, Canon Ledger with fingerprint-bound route state. So the mechanism is not new, and the write-up should not pretend otherwise.

What differs here is narrower than an earlier draft of this claimed, and it is two things.

The fingerprint is of the page's declared structure, not of the artefact a person approved. Understudy's allowlist is bound to what its recording did; Agentic Service Dispatch's approval is bound to the draft it approved. Both catch a change to the thing that was agreed. Neither catches a field being reclassified six months later by someone who never heard of that tool — which is the failure this page is built around, because a tool a person minted on a Tuesday is maintained by nobody.

And the consequence is removal, not refusal. Reflex refuses execution when its fingerprint stops matching, which is the right call at call time. Here the tool leaves getTools() entirely, so an agent listing the page's tools never sees it and never forms a plan around it. bench/host-check.py verifies that through the host rather than through this page's own bookkeeping.

So a minted tool records a fingerprint of the structure it was approved against. When the page changes — reclassify a field in the tool surface panel and see it — the tool's contract is re-validated. If it now returns something policy forbids, the component unmounts, its AbortSignal fires, and the browser stops offering it. The accepted edge cases become a regression suite: one click re-runs them, and any case whose behaviour changed comes back unreviewed, keeping the tool out until a person approves it again.

A tool that goes quiet is better than a tool that keeps answering confidently about a page that has moved.

The capability outlives the session, and then the person who made it

A tool that vanishes on reload is a demonstration. Minted tools are kept per site — the contract, the reviewed edge cases, the fingerprint they were approved against — so the surface a person built is there when they come back, and is not there on a site it was not made for. Working state is deliberately not kept: a half-written proposal or a write waiting on confirmation belongs to the session. What someone made does not.

And it can be shared as a link. The link carries the contract, not a registration: the receiving page re-validates it against its own structure and re-runs every edge case on its own data before that person is asked to approve. Fields their page classifies as restricted stay locked even if the sender exposed them.

So the work stops being personal. The person who actually does the job every month creates the capability, and the rest of the team's agents — different models, different browsers — get it after their own page has checked it. Nobody filed a ticket and nobody deployed anything.

Challenges

The spec is younger than any model's training data, and models confidently write server-side MCP (McpServer, server.tool(), transports) which does not exist here. I worked from the explainer directly. I also found that requestUserInteraction(), which ten write-ups here describe as though it exists, appears nowhere in the current spec or in Chrome's repositories — so this project does not claim it. Human confirmation is implemented as a tool call that resolves only after a person acts, which the current API supports.

The harder design problem was where generalisation should live. Rules are brittle, a model alone is unaccountable, and a person asked to specify everything will not bother. Splitting it — structure from the page, inference from the agent, authority from the person — is the whole design, and the page keeps a rule-based draft so the demo never depends on a model behaving.

One piece of feedback for the spec

Running this against Chrome rather than reading about it produced two observations, and being precise about them matters more than the observations themselves.

The first is a calling convention: document.modelContext.executeTool(tool, args) takes its arguments as a JSON string and returns its result serialized the same way, while the tool's own inputSchema describes an object and its execute receives one. The object form fails with UnknownError: Failed to parse input arguments, which does not point at serialization. I first wrote this up as something no other entry mentions. Checking the corpus showed otherwise: thirty-four write-ups mention executeTool, and depending on how tightly you match, thirteen to fifteen of them describe the string argument. Three quote the same error text. That is a better argument for documenting it than novelty would have been.

The second is about annotations, and the mistake was mine. This page was sending destructiveHint, idempotentHint and openWorldHint, which Chrome accepts and discards. WebMCP defines exactly two annotations; those three belong to the wider MCP vocabulary. Placard, another entry here, says so and enforces it with a test. The page now sends only readOnlyHint and untrustedContentHint, and the type makes the others unrepresentable.

What is left after the correction is a request rather than a bug. A page that lets a person mint a tool that acts has one bit to describe it with. readOnlyHint: false says "this is not a read"; it cannot distinguish appending a note from deleting a record, and it cannot carry how many rows one call will touch — which is exactly what the approver was shown and exactly what the client calling it later cannot see. This page compensates with a description and a confirmation step it invented itself. A host cannot act on either.

All of it is written up as a filable issue in docs/spec-issue.md — in the spec's own terms, with nothing in it that depends on this project. It is not filed: that is a public action on my account and saying so is more useful than implying otherwise.

That request sits alongside three others in docs/spec-note.md: a declared context class per field, a conventional shape for "an identifier the agent may pass back but not read", and a structural fingerprint tools can be bound to. Twenty-four entries use the word fingerprint, but most of them mean something else — stable selectors, DOM digests, consent tokens. Checking each: four bind a capability to a revision or a fingerprint — Captain's Table with capability epochs, Cadence with exact-revision validation, Canon Ledger with fingerprint-bound route state, StillHere with draft, key, version and approval sharing one. Reflex refuses execution when a semantic fingerprint stops matching. Assistant Studio binds consent rather than a tool. That is the defensible count, and it is smaller than the one an earlier draft gave. Four incompatible schemes for one problem is still the signal — that people keep inventing it is the argument for putting it in the spec.

What I learned

Four things, each of which cost a claim this write-up had already made:

  • Attacking the running page found what reading it did not. Three real holes — grouping put a withheld field in the key position, a filter on an unfilterable field made the row count an oracle, and a reclassified field's filterOps survived in an already minted tool. All three were in code I had read and believed.
  • The same fix has to be found in every place it belongs. One site read the policy live and refused correctly; the other had snapshotted it at module load and did not.
  • A corpus claim is wrong until it is counted, and then counted again. 169, then 470, then 868. Every count in this write-up moved in the unflattering direction on the last recount.
  • Two of my own sentences contradicted each other in the document whose thesis is that every checkable claim was checked. Both are fixed and both are recorded.

The long version, with each retraction named: docs/what-i-got-wrong.md.

What's next

The four layers this page publishes about itself are a guess at a general shape. If pages are going to let people mint tools, they will need a shared way to say what may vary, what may be composed, and what may never leave. That belongs in the spec conversation, not in one app.

Built With

Share this project:

Updates

Submission history