Driving the webapp with an agent
The web client publishes its own actions as WebMCPÂ tools, so an agent can open a share, walk it, read files, seed them and publish new ones without installing anything.
That is the point of doing it in a browser. The native path needs the Rust
binary; npx agent-share needs the node-datachannel addon, because Node has
no RTCPeerConnection. A browser already has WebRTC, a sandbox and storage. The
tab becomes the runtime.
Registration lives in packages/agent-share-web/src/lib/webmcp/. It is feature-detected, so on a
browser without WebMCP — which today is every browser by default — it reads one
property and does nothing.
What you need
- Chrome 150 or newer, started with
--enable-features=WebMCP. chrome-devtools-mcp, which is what actually carries the tools to an agent. It launches Chrome itself, so it can turn the feature on for you.
WebMCP is a W3C Community Group draft, not a standard. Chrome is the only
implementation; Edge, Firefox and Safari have nothing. And the spec only covers
publishing tools — getTools() and executeTool() are still marked
TODO: Spec and describe, and the explainer assumes an agent built into the
browser. So the page half below is stable, and the connection half is not.
Setup
claude mcp add chrome-devtools-webmcp --scope user -- \
npx -y chrome-devtools-mcp@latest \
--categoryExperimentalWebmcp \
--chromeArg=--enable-features=WebMCPBoth flags are load-bearing and neither implies the other: --categoryExperimentalWebmcp
exposes the two MCP tools, and --chromeArg=--enable-features=WebMCP turns on
the browser feature they read. With only the first, the tools exist and always
return an empty list.
If you already have the chrome-devtools-mcp plugin installed, add this as a
separate entry rather than editing the plugin: its arguments are fixed and a
plugin update would overwrite them.
Using it
navigate_page https://agent-share.dev/app/files/<ticket>
list_webmcp_tools -> the six tools below
execute_webmcp_tool { toolName: "read", input: "{\"path\":\"src/lib.rs\"}" }The ticket in the page URL is the default for every tool, so once you have
navigated you can call connect with no arguments at all.
The tools
| Tool | Does | Changes state |
|---|---|---|
connect | Open a share; report its contents and the live connection | no |
list | A directory’s entries, or one file’s size and local coverage | no |
read | Read a byte window of one file | no |
search | Find a string across the share’s text files | no |
sync | Pull files into browser storage; this tab then seeds them | yes |
publish | Publish files as a new share and return a ticket | yes |
They work on any route and need nobody present. There is nothing here that moves the page, selects a file, or switches a view, and that is the line the surface is drawn on: WebMCP is the agent’s interface, the screen is the person’s. An agent that could move a tab out from under someone reading it has been handed a capability nobody asked it to have, and the tools it would need — a selection, a current view — are meaningless to a caller that cannot see them anyway.
Two capabilities are therefore absent rather than broken. Nothing here downloads
to a filesystem or mirrors into a local folder: both need showSaveFilePicker
or showDirectoryPicker, which only a real click can open, and no directory
handle is persisted anywhere. For bulk transfer to a filesystem, use
agent-share mirror. To pull bytes into the browser instead — where later reads
are local and the tab starts seeding — use sync.
Every result is flat and carries ok. A failure is { ok: false, code, error }
with a stable code — not_found, unauthorized, bad_argument,
not_a_file, no_session, unsupported, failed.
Opening and re-opening
connect is idempotent. The first call dials; a later one refreshes the
file list and re-reads the connection, so it doubles as a status check and there
is no separate status tool:
{ "ok": true, "ticket": "SvL6pH…", "files": 128, "directories": 12,
"bytes": 4210433, "transport": "webrtc", "closed": false,
"peersOnMesh": 3, "peersDirect": 1, "maxDirect": 4,
"filesHeldLocally": 0, "skippedEntries": 0 }Listing
list answers for whatever the path names, and says which it got in kind. A
directory returns entries depth levels deep, plus the rollup of everything
beneath it; a file returns its size and how much of it this browser already
holds. depth means nothing on a file and is ignored there:
{ "ok": true, "kind": "dir", "path": "src", "entries": [ … ], "count": 9,
"files": 40, "bytes": 210433 }
{ "ok": true, "kind": "file", "path": "src/lib.rs", "name": "lib.rs",
"size": 210433, "mtime": 1755000000, "index": 7,
"held": false, "coverage": 0.25 }The rollup is free: buildTree totals every directory as it builds one, so
asking how big a folder is never walks it.
Reading a file
read is windowed. It returns UTF-8 text, or data as base64 when the
bytes are binary, plus size, eof and nextOffset. Follow nextOffset until
eof is true:
{ "ok": true, "path": "src/lib.rs", "encoding": "utf8", "text": "fn main() {…",
"offset": 0, "length": 65536, "size": 210433, "eof": false, "nextOffset": 65536 }The default window is 64 KiB and the maximum is 256 KiB, which is the protocol’s
own cap on a READ. Bulk transfer is deliberately not a tool: the browser cannot
write to your filesystem without a person clicking a folder picker, so moving a
large tree is still agent-share mirror’s job.
Publishing
publish writes into the origin private file system and serves it with the
same producer the Add folder button uses, so no user gesture is involved and
the result is an ordinary share a native peer can read:
publish { files: [{ path: "notes.md", content: "# hi\n" }] }
-> { ticket: "SvL6pH…", url: "https://…/app/files/SvL6pH…", files: 1 }
agent-share mirror SvL6pH… ./out # works from the CLIIt cannot share a folder that already exists on your disk — that needs
showDirectoryPicker() and a real click. And the share lives only as long as
the tab: the bytes sit in per-origin browser storage, under that origin’s quota
and subject to eviction.
Telling the user an agent is here
The name of the app in the top bar carries it. agent-share turns green the
first time any tool is called and stays green; while a call is running, or for
ten seconds after one, its characters shimmer between full and dimmed green.
Hovering gives the sentence — which tool, how many times, how long ago — and
before anything has happened it says how many tools are published, or that this
browser has no WebMCP at all. See packages/agent-share-web/src/components/brand.
The wording is careful, because the thing you would want to show cannot be
observed. WebMCP lets a page publish tools; it never tells the page that
something has connected to them, and the spec has no notion of an agent session
at all. getTools() is a caller’s API — a page calling it learns about its own
tools, not about who else is looking. A tab whose tools nobody has ever called
is indistinguishable from a tab no agent has found.
So the colour is driven by the only real evidence, a tool actually being invoked, and it never claims more than that. The name is untouched until the first call, and once the active window lapses the tooltip drops the present tense and says outright that the agent may no longer be attached. Do not “improve” it into a connected/disconnected indicator; there is nothing to drive one with.
Two states rather than three, and green never decays back to grey: the page cannot un-know that an agent was here, so only the movement is allowed to stop.
Browser behaviour worth knowing
Measured against Chrome 151, and the reason parts of the implementation look the way they do. Some of it contradicts the published examples.
document.modelContext, notnavigator.modelContext. Thenavigatoralias still exists but is deprecated as of Chrome 150 — the same version that the WebMCP tooling requires.executeTooltakes aRegisteredTool, not a name. Get the object fromgetTools()first. Passing a string throwsThe provided value is not of type 'RegisteredTool'.- Its
inputmust be a JSON string. An object throwsFailed to parse input arguments. - Input is never validated against
inputSchema. A call omitting arequiredfield runs anyway, with that fieldundefined. Every tool therefore checks its own arguments. - A throw is flattened. Whatever a tool throws reaches the agent as
UnknownError: Tool was executed but the invocation failed, with the message stripped. So failures are returned as data instead — henceok: false. - Return values are serialized to a string. Returning a plain object is fine; it arrives JSON-encoded.
- Registration is per-call and asynchronous. The tools are registered
concurrently, because until the last one lands
getTools()returns a partial list and says nothing about being incomplete. - Unregistering is only possible through an
AbortSignalgiven at registration time.
Checking it works
Without any agent, open the page in Chrome 150+ with the feature flag and look at DevTools → Application → WebMCP. Available Tools lists what the page published; Invoked Tools logs each call with its input and output.
From the console:
const tools = await document.modelContext.getTools()
await document.modelContext.executeTool(
tools.find(t => t.name === 'list'), '{}')