-
-
Every number with the command that produced it: 0 identifiers returned, against 1,552 with the policy layer deleted.
-
Taught with one month, asked for another. It follows the page's calendar instead of freezing on the month she demonstrated.
-
"Who is emp_03u6yn?" No tool on this page returns it. Not discouraged: there is no code path.
-
What an agent sees: 8 = 7 + 1. Seven the author wrote, one she made, read from getTools() rather than the page's own notes.
-
Why each literal should vary, in the agent's own words, beside the schema the page generated. She can disagree with either.
-
The workbench this team already uses. 1,680 rows, eight controls, and the seven tools its author wrote.
-
An acting tool never acts alone. The call suspends, naming what it will do to how many people, until she confirms.
-
A real agent's proposal, before the tool exists. OpenAI's Codex read the page's structure; the page validated what came back.
-
The work done once, by hand. The log records what each operation meant, not where she clicked.
-
Six months on, somebody reclassifies a field. They never heard of her tool and are changing something else.
-
Its fingerprint no longer matches, so it leaves getTools() — gone from the list, not refused when called — until re-approved.
-
Approval calls registerTool(). The tool exists now and declares the three fields it will never return.
-
Eight edge cases generated from the page's own declaration and already run against real rows, before the tool exists.
-
One control turns the proposal into a tool that acts, not only answers, with an edge case for how many people one call reaches.
-
The person who runs the report writes the agent's tool. The same five rows twice: names on her screen, handles in what a tool returns.
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.
- 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. - An agent drives her screen.
run_querysets 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. - She demonstrates. Month
2026-08, overtime above40. Two operations recorded — what they meant, not where she clicked. - 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 backNo field named month. - 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.
- Approve.
registerTool()runs. The surface reads 8 = 7 + 1. - Ask it for a month nobody demonstrated: "the one before" resolves to
2026-07. Ask whoemp_03u6ynis — no tool returns it. Ask to flag them and the call suspends inside the page until she decides; decline and the agent getsdeclined_by_human, nothing written. - 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
monthis a date dimension that may carry a relative default, thatdepthas six legal values, and thatnational_idmay never leave. An agent reading pixels cannot know any of that. The page publishes it as four layers — data, operations, view, policy — throughget_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_toolcannot mint anything. It returnsawaiting_human_approvaland 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.modelContextat load. The call is written out insrc/webmcp/registry.tsrather 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 theAbortSignalthe spec provides: unmounting a tool's component aborts it. - Minted tools are React components. Each holds one
registerToolregistration, 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_toolaccepts 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 withoutrelative_okcannot 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
filterOpssurvived 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
- chatgpt
- react
- typescript
- vercel
- vite
- webmcp
Log in or sign up for Devpost to join the conversation.