A compatibility layer for the agentic web. Reflex reads a website's forms, buttons and accessibility metadata, proposes WebMCP tools from what it finds, and — once a human approves them — registers those tools so an agent can drive the site through a structured interface instead of by clicking pixels.
reflex-three-cyan.vercel.app · Watch the demo · Download the extension · Watch the 47s demo · Quick start · How it works · On real websites · Safety
The middle 26 seconds, looping: an agent calls search_claims({"query": "Okonkwo"}) and the
service's own form is filled and submitted — driven, not bypassed — then a destructive tool is
called and stops to ask a human, who declines, and the claim is left untouched.
▶ Watch the full 47 seconds on YouTube — narrated, with the discovery and approval steps at the start. A silent MP4 is in the repository if you would rather not leave it. A narrated cut, and WebM versions of both, are attached to the release — its voiceover is macOS text-to-speech and its interface cues are synthesised tones, so nothing in the soundtrack needs a licence.
Most websites were built before WebMCP existed. Adapting each one by hand means a developer identifying capabilities, naming tools, writing descriptions, authoring JSON Schemas, implementing handlers and maintaining registrations — which is exactly the adoption barrier that keeps agents stuck driving user interfaces visually: fragile, slow, and ambiguous.
But those websites are not silent about what they do. They already describe much of their functionality — through forms, semantic HTML and the accessibility metadata they were required to add anyway.
Reflex treats accessibility metadata as evidence for a capability contract. Existing websites contain enough semantic information to bootstrap an agent interface. Reflex turns that implicit interface into an explicit WebMCP one.
flowchart TD
A["Existing website"] --> B["DOM + ARIA + semantic HTML"]
B --> C["Capability discovery<br/><i>forms, buttons, labels, roles</i>"]
C --> D["Tool generation<br/><i>name · description · JSON Schema</i>"]
D --> E["Confidence + risk analysis"]
E --> F["Human review"]
F -->|approved| G["WebMCP registration"]
F -->|rejected| H["Nothing registered"]
G --> I["Agent operates the existing app"]
style F fill:#fff6e5,stroke:#b58105,stroke-width:2px,color:#1c2430
style H fill:#f7f7f7,stroke:#999,color:#555
style G fill:#e6f7ef,stroke:#1f6f4f,stroke-width:2px,color:#1c2430
style I fill:#e8f0fb,stroke:#1f4e8c,stroke-width:2px,color:#1c2430
Human review is not a setting to switch off. It is the product: generated tools can be wrong, and the person approving one gets to see exactly which markup produced it.
A form the service already had:
<form aria-label="Search claims"
aria-description="Find a claim by reference number, claimant name or policy number">
<label for="claim-query">Reference, claimant or policy number</label>
<span class="hint" id="claim-query-hint">For example CLM-2026-0481, Okonkwo, or POL-4471-882.</span>
<input id="claim-query" name="query" type="text" required maxlength="60"
aria-describedby="claim-query-hint" />
<button type="submit">Search claims</button>
</form>The tool Reflex proposes from it — name, description, schema and the field's own hint, all lifted from markup that was already there:
{
"name": "search_claims",
"title": "Search claims",
"description": "Find a claim by reference number, claimant name or policy number.",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"maxLength": 60,
"description": "For example CLM-2026-0481, Okonkwo, or POL-4471-882."
}
},
"required": ["query"],
"additionalProperties": false
},
"risk": "read",
"confidence": 100
}Calling it sets the field, dispatches input and change, calls form.requestSubmit(), and returns
the text of the region the form updates — so a read-only tool returns data, not just
"submitted".
Every approved capability is registered through WebMCP's registerTool, in exactly the shape the API
documents:
document.modelContext.registerTool({
name: "search_claims",
description: "Find a claim by reference number, claimant name or policy number.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", maxLength: 60, description: "For example CLM-2026-0481" }
},
required: ["query"],
additionalProperties: false
},
annotations: { readOnlyHint: true, destructiveHint: false },
execute: async (input) => executeCandidate(document, candidate, input)
});The point of the project is that Reflex writes that object for you, from markup the page already
had: name and description from aria-label and aria-description, inputSchema from the form's
own controls and their labels, annotations from keyword risk classification, and execute from a
stable selector plus a semantic fingerprint that is re-verified before anything actuates.
One indirection is deliberate. packages/webmcp-adapter/src/adapter.ts
is the only file in the codebase that touches modelContext, because WebMCP is experimental and its
surface is still moving. It probes navigator.modelContext, then document.modelContext, supports
both registerTool and provideContext host styles — and when the browser offers neither, installs a
local host under both names, so a client written against either spelling finds it. Registration,
execution and withdrawal all go through that one interface; when a browser ships a native host, it is
used and nothing else changes.
Download: latest release — or
release/reflex-extension-0.4.1.zip from the tree (76 KB)
Unzip it, then:
- Open
chrome://extensions - Turn on Developer mode (top right)
- Click Load unpacked and select the unzipped
reflex-extensionfolder - Pin Reflex to the toolbar (puzzle-piece icon → pin)
Or build it from source
npm install
npm run build:extension # → apps/extension/dist (load this folder unpacked)
npm run package # → release/reflex-extension-<version>.zipnpm run dev:demo # → http://localhost:3000/claimsNational Claims Portal is a fictional government insurance service included in this repo — the Department of Insurance Services for the (entirely invented) State of Marisol. Public-sector services are the right subject for this: they are form-heavy, they are the software people are actually forced to use, and their accessibility metadata is genuinely excellent because it had to be.
It uses nothing but standard HTML and good ARIA — no Reflex-specific hooks — and it deliberately plants decoys ("Toggle navigation menu", "Dismiss this notice", "Print this page", "Next page", "Expand all rows") that must not become tools.
Open the app and click the Reflex button.
A REFLEX TOOLS panel appears in the page. It stands in for a WebMCP client — no shipping browser exposes WebMCP yet — listing exactly what was registered and calling it with JSON arguments. Or drive it from the page's DevTools console, the way a real client would:
navigator.modelContext.listTools().map((tool) => tool.name);
// → ['search_claims', 'request_claim_review']
await navigator.modelContext.callTool('search_claims', { query: 'Okonkwo' });
// → { success: true,
// observed: { region: 'CLM-2026-0481 Amara Okonkwo Flood damage 12,400.00 AWAITING DOCUMENTS' } }On /claims/CLM-2026-0481 — a flood-damage claim
awaiting documents — Reflex finds eight capabilities spanning all four risk levels:
| Capability | Risk | Conf. | Behaviour |
|---|---|---|---|
list_claim_documents |
read |
100% | Lists documents on file, and returns the table's contents |
add_supporting_document |
write |
100% | Records a document — the file input is skipped, since an agent cannot supply one |
request_claim_review |
write |
100% | Sends the claim back to an assessor |
update_correspondence_address |
write |
100% | Changes where letters go |
authorise_payment |
sensitive |
100% | Releases money — the act is consequential |
set_claim_access_pin |
sensitive |
55% | Schema is empty: its only field is a password input |
delete_supporting_document |
destructive |
100% | Removes a document — asks you first, in the page |
withdraw_claim |
destructive |
75% | Closes the case permanently — asks you first |
Four things worth trying:
-
A read tool that returns real data. Enable
list_claim_documentsand call it with{"verification": "verified"}. The table filters, and the tool returns what the page then showed — not a bare "submitted". -
A write tool moving the case along. Enable
request_claim_reviewand call it with{"reason": "valuation-dispute"}. The claim's stage becomes Under review and the case history gains an entry, because the service's own form was driven rather than bypassed. -
Human approval on a destructive call. Enable
withdraw_claimand call it. Chrome asks you, in the page, every time. Dismiss it and nothing happens; accept and the claim closes. -
Break the fingerprint. With
request_claim_reviewenabled, run this in the page console and call the tool again:document.getElementById('request-review').setAttribute('aria-label', 'Reject claim outright');
It refuses:
accessible name changed from "Request claim review" to "Reject claim outright". The selector still matched. The meaning didn't.
The register at /claims is where the schema generation shows off —
file_new_claim yields nine parameters from one form, including a policy-number pattern, a
claimType enum, a date, a number bounded 100–500,000, a radio group and a checkbox. And
/policies demonstrates the classification split: check_policy_status
is a read, renew_policy a write, cancel_policy destructive.
Discovery is the easy half. On one YouTube watch page Reflex finds 57 candidates, every one scoring
55%, named 1_reply, 150_replies, 166_replies — the "N replies" button on every comment. A list
that long is not a review queue, it is a wall, and a wall is worth exactly as much as an empty panel.
So every scan is triaged before it reaches you:
Two floors, not one. A flat confidence floor is the wrong instrument, because real working
capabilities on well-built government forms also score 50–60% — Companies House's
search_the_register and the NHS pharmacy finder both do, having never written an
aria-description. What separates them from the noise is the source: a form arrives with a typed
schema, which is evidence in itself and something a reviewer can judge; a button is an
unparameterised action inferred from two words of label, so it needs corroboration. Forms are held to
50%, buttons to 70%.
Counts are not capabilities. A label beginning with a number — "1 reply", "349 languages" — was a tally, not an action. Those are held back on sight.
Indistinguishable duplicates collapse. show_cards, show_cards_2 and show_cards_3 become one
row carrying ×3, because the page gave no way to tell them apart, and an agent could not choose
between them either.
Nothing is discarded. Everything held back stays one click away behind show N held back, each row explaining why it was held, and any of them can still be enabled.
A score explains itself. 55% tells a reviewer nothing on its own, so the inspector lists what
the page failed to declare: no ARIA description, no field labels, declares fields but exposes
none.
The result on the page above: 0 shown of 13 found — and a sentence saying why, instead of thirteen rows of noise. On the demo service it is 8 of 8, because that service actually declares what it does.
Reflex is not demo-ware. Measured with npm run scan -- <url>, which runs the real discovery engine
against a live page:
| Site | Readiness | Discovered |
|---|---|---|
| GOV.UK search | 95% | site_wide(keywords) + a 10-parameter filter form |
| NHS pharmacy finder | 90% | find_a_pharmacy(Location) |
| Companies House | 89% | search_the_register(q) |
| Wikipedia | 82% | searchform(search) — named from an id, so worth renaming |
| GitHub advanced search | 72% | one form → 25 parameters (search_stars, search_license, search_author, …) |
| Hacker News | 50% | nothing — forms without accessible names |
| OrangeHRM demo | 39% | nothing — placeholder-only labels |
The top three were verified end to end: approve the tool, call it through navigator.modelContext,
and the site performs the real search. Their Content-Security-Policy is no obstacle, because Chrome
injects the runtime through a privileged path rather than a <script> tag.
The low scores are the honest half of the story, and they are what the readiness breakdown is for: it names which signal is missing — accessible names, form quality — instead of just reporting that a page did not work.
npm run scan -- https://www.gov.uk/search/all # read-only: discovers, never calls
npm run scan -- --threshold 40 --json https://example.comThe default design is Civic — the language of public-service software: plain words lead, the tool name recedes, risk is a solid tag rather than a coloured dot, and the type is heavy enough that approving something feels like a decision. That suits what the panel actually is: the thing standing between an agent and your account. It ships in light and dark, following your system unless you choose.
Four other designs are built in, because which one is right depends on who is reviewing — a developer auditing their own app wants density; someone deciding whether an agent may move money wants gravity. Switch in Settings.
All five share one component tree and one set of decisions: every row renders each design's parts — a mono tool name, a human title, a plain-language sentence, a stripe, a tick — and the stylesheet decides which are visible. The triage and approval logic cannot drift between designs, and an end-to-end test asserts each one still renders the same eight capabilities and still registers a tool.
| Setting | Default | What it controls |
|---|---|---|
| Panel design | Civic | Which of the five looks the panel wears |
| Light / dark | System | Civic's two modes; the other four are single-look by design |
| Reflex enabled here | On | Per origin. Turning it off withdraws every registered tool at once |
| Stay attached after a reload | Off | Asks Chrome for this one site, so approved tools come back on their own |
| Confirm destructive calls | On | Asks in the page before a destructive tool actuates, every time |
| Rescan when the page changes | On | Keeps the list in step with what is on screen |
| Minimum confidence | 50% | The floor before triage, which then judges forms at 50% and buttons at 70% |
| Provide a local WebMCP host | On | Registers tools even where the browser exposes no host |
| Show the in-page tool console | On | The stand-in WebMCP client used for demos |
A Chrome content script cannot reach the page's own JavaScript, and navigator.modelContext lives
there. So Reflex is split, with a strict message protocol between the halves:
flowchart LR
subgraph EXT["Chrome extension"]
P["Popup (React)<br/><i>review · approve · settings</i>"]
SW["Service worker<br/><i>badge only</i>"]
end
subgraph ISO["Content script · isolated world"]
D["Discovery engine"]
S["chrome.storage.local<br/><i>approvals, per origin</i>"]
M["MutationObserver<br/><i>debounced rescan</i>"]
end
subgraph PAGE["Page runtime · page's own world"]
A["WebMCP adapter"]
X["DOM executor"]
C["Tool console"]
end
P <-->|chrome.runtime| D
D --> S
M --> D
D <-->|"window.postMessage<br/>channel: reflex/v1"| A
A --> MC["navigator.modelContext"]
A --> X
X --> DOM["The page's own forms<br/>and buttons"]
MC <--> AG["Agent"]
style PAGE fill:#f4f8fd,stroke:#1f4e8c,color:#1c2430
style ISO fill:#f7f7f9,stroke:#5a6673,color:#1c2430
style EXT fill:#f3f1fa,stroke:#5b4b9c,color:#1c2430
The content script decides what exists and holds the user's decisions. The page runtime decides
nothing — it registers what it is told to and executes it. Neither half can do the other's job,
which is what keeps modelContext access out of the extension and DOM policy out of the page.
sequenceDiagram
autonumber
participant U as You
participant Pop as Popup
participant CS as Content script
participant PR as Page runtime
participant Host as WebMCP host
participant Ag as Agent
participant Web as The website
U->>Pop: Open Reflex
Pop->>CS: inject + REQUEST_SNAPSHOT
CS->>CS: scan DOM → candidates + evidence
CS-->>Pop: capabilities, confidence, risk
U->>Pop: review evidence, edit, Enable
Pop->>CS: APPROVE_CANDIDATE
CS->>PR: REGISTER_TOOL
PR->>Host: registerTool(name, schema, execute)
Ag->>Host: callTool("change_department", {...})
Host->>PR: execute(args)
PR->>PR: re-verify semantic fingerprint
alt fingerprint matches
PR->>Web: set fields, dispatch events, requestSubmit()
Web-->>PR: page updates
PR-->>Ag: success + text of the result region
else element changed or vanished
PR-->>Ag: error — nothing actuated
end
| Signal in the page | What Reflex derives |
|---|---|
aria-label, aria-labelledby |
tool name and title |
aria-description, aria-describedby |
tool description |
<label for>, wrapping <label> |
parameter descriptions |
<legend>, adjacent headings |
fallback names for forms |
type, required, min/max, pattern, <option> |
JSON Schema, enums, constraints |
aria-controls, live regions, role="status" |
what the tool returns to the agent |
| the verbs in those labels | risk classification |
Names resolve in a fixed priority — aria-label → aria-labelledby → heading/legend → visible text
→ name → id — and every candidate carries the evidence that produced it, so a reviewer can
see why Reflex proposed something instead of taking its word.
Generic interface mechanics never become tools. "Close", "Toggle sidebar", "Next page", "Move tools to sidebar" are dropped by rules calibrated against real pages — an agent should invoke a business capability, not operate incidental UI.
HTML → JSON Schema mapping
| HTML | JSON Schema |
|---|---|
text, search, tel, <textarea> |
string (+ minLength, maxLength, pattern) |
email |
string, format: email |
url |
string, format: uri |
number, range |
number (+ minimum, maximum); integer when step is whole |
date / time / datetime-local |
string + format: date / time / no format |
checkbox (single) |
boolean |
checkbox (shared name) |
array of enum |
radio group |
string with enum of the group's values |
<select> |
string with enum; array when multiple |
required |
added to the schema's required list |
password |
never exposed; escalates the tool's risk to sensitive |
hidden, file, submit, disabled, unnamed |
skipped — no agent-settable value |
An additive heuristic over the signals above, capped at 100:
| Signal | Points |
|---|---|
aria-label present |
+30 |
aria-description present |
+20 |
| every field has an authored label | +15 |
a real <form> element |
+15 |
every field has a name |
+10 |
| explicit button text | +10 |
| field-level help text | +10 |
| declares fields but exposes none | −20 |
90–100 high · 75–89 review recommended · 50–74 low · below 50 is not shown at all
(adjustable in settings).
In practice, on the demo service: search_claims scores 100 (ARIA label, ARIA description, labelled
and named field, real form, submit button, field hint), withdraw_claim 75 (a button, so no fields
to score), and set_claim_access_pin 55 — it declares a field but exposes none, because that field
is a password.
Keyword rules, checked most-dangerous-first, so "Search and revoke access" is destructive rather
than read. Unknown verbs default to write.
| Level | Decided by | Consequence |
|---|---|---|
read |
a leading read verb — search, view, list, filter, check | the only level eligible for bulk approval |
write |
a leading write verb — create, add, update, file, request, renew | individual approval |
sensitive |
a consequential action (authorise, approve, send, reset, grant), or a write against a sensitive subject (password, bank, payment, role, access) | individual approval |
destructive |
delete, remove, revoke, withdraw, cancel, terminate, archive | individual approval and a confirmation in the page on every call |
Two refinements matter here, both forced out by real markup:
Sensitive actions are distinguished from sensitive subjects. authorise_payment moves money, so
it is sensitive. search_payments only looks, so it is a read — flagging it would be the kind of
warning people learn to click past.
The leading verb wins. "View claim record" is a read even though record is also a write verb,
and "List documents recorded against this claim" is a read despite recorded in its description.
Risk is advisory and editable in the inspector — the keyword that decided it is shown, so a misclassification is visible rather than mysterious.
Every candidate stores a stable selector and a semantic fingerprint: tag, role, accessible name, and the field names it expects. Before actuating anything, Reflex re-locates the element and re-checks that fingerprint. A form that lost a field, or a button whose accessible name changed, produces an error — never a click on whatever now occupies that selector.
Values are set through native property setters followed by input and change events, so
frameworks notice the change; submission prefers form.requestSubmit() so the page's own validation
and handlers run. On multi-page apps, execution detects the page starting to navigate and returns
promptly with observed.navigating: true instead of waiting for a result it could never read.
| Product page | reflex-three-cyan.vercel.app |
| Install | latest release → unzip → chrome://extensions → Load unpacked |
| Build from source | npm install && npm run build:extension (Node ≥ 18.18) |
| Demo service | npm run dev:demo → http://localhost:3000/claims |
| WebMCP API used | document.modelContext.registerTool({ name, description, inputSchema, execute }) — see The WebMCP surface |
| Tests | npm test (183, jsdom) · npm run test:e2e (25, real browser) · CI on every push |
| License | MIT |
| Project story | docs/project-story.md |
| Demo video | youtu.be/QjSB0LTPJVI |
reflex/
├── apps/
│ ├── extension/ Chrome extension (Manifest V3)
│ │ └── src/
│ │ ├── content/ discovery, decisions, storage, the bridge
│ │ ├── page/ WebMCP registration, DOM execution, tool console
│ │ ├── popup/ React review UI
│ │ ├── background/ badge only
│ │ └── shared/ messaging, storage, settings
│ └── demo-legacy-app/ National Claims Portal (the fictional service)
├── packages/
│ ├── capability-model/ CapabilityCandidate, schema + message types
│ ├── discovery-engine/ scanners, ARIA/labels, naming, risk, confidence,
│ │ selectors + fingerprints, readiness
│ ├── schema-generator/ HTML form controls → JSON Schema
│ └── webmcp-adapter/ the only code touching modelContext, + executor
├── apps/landing/ product page (static, deployed to Vercel)
├── tools/
│ ├── scan-site.mjs run the engine against any live URL (read-only)
│ └── package-extension.mjs build the loadable zip
├── release/ the packaged extension
├── docs/screenshots/
└── tests/
├── unit/ Vitest (jsdom)
└── e2e/ Playwright, against the real built extension
The discovery engine has no extension dependencies, so it can be reused outside Reflex — which is
what npm run scan does.
| Command | What it does |
|---|---|
npm run build:extension |
Build into apps/extension/dist (load this unpacked) |
npm run package |
Build and zip into release/ |
npm run dev:demo |
Serve the demo app on port 3000 |
npm run dev:landing |
Serve the product landing page locally |
npm run scan -- <url> |
Point the discovery engine at any live page |
npm test |
183 unit tests (jsdom) |
npm run test:e2e |
25 end-to-end tests in a real browser |
npm run typecheck |
Typecheck every workspace |
- Nothing registers itself. Every tool requires an explicit human approval. "Enable read-only
tools" is the only bulk action, and it touches nothing above
read. - Approvals are scoped by origin, in
chrome.storage.local. A tool approved on one site never appears on another — including the same application served from a different host. - Destructive calls ask again, in the page, every single time an agent invokes them.
- Password fields are never exposed. A form containing one has that field omitted from the schema
and its risk escalated to
sensitive. - Targets are re-verified before execution, and execution fails closed when the element's meaning has changed.
- Discovery is local. No page content, DOM, form values or account data leaves the browser. Reflex has no backend, and the MVP uses no model at all — discovery is deterministic rules over markup.
- Minimal permissions by default:
activeTab,scripting,storage, and no host permissions — Reflex reads a page only when you open it there. A site can be granted individually, from Settings, to re-attach after reloads; that grant covers that one origin and is revocable from either side. - One switch off. Disabling Reflex for a site withdraws every registered tool immediately.
WebMCP is experimental and not in stable Chrome. packages/webmcp-adapter is the only place that
knows this:
- probe
navigator.modelContext, thendocument.modelContext - support both
registerToolandprovideContexthost styles - failing both, install a local host marked
__reflexShim: trueso approved tools are still real and callable
The popup always states which of those is in play. When a browser ships a native host, the adapter uses it and nothing else in the codebase changes.
npm test # 183 unit tests (jsdom)
npm run test:e2e # 25 tests in a real browser, against the built extensionUnit tests cover naming, ARIA and label resolution, the full HTML → JSON Schema mapping, ignore rules
(including real labels harvested from live sites), risk classification, selector and fingerprint
generation, the adapter against three host styles, DOM execution — and discovery run over the demo
service's own rendered markup, so a regression in its accessibility is a test failure. Those
tests also assert the classification split directly: search_payments must stay read while
authorise_payment must be sensitive.
End-to-end tests need Playwright's Chromium once:
npx playwright install chromiumStable Chrome no longer honours --load-extension, so the suite uses Chromium
(REFLEX_E2E_EXECUTABLE=/path/to/Chromium points it at a build you already have). It stages the
built extension with one change — a host permission for localhost:3000, standing in for the click
that would otherwise grant activeTab — then injects Reflex exactly as the popup does, drives the
real popup UI, and calls the registered tools the way a WebMCP client would.
MIT © 2026 Birat Datta










