Inspiration
I wanted to build the thing I keep failing to get out of a chatbot: help with the work already on my screen. Every scheduling tool I have used either decides for you or leaves you alone with a number that does not say why. So I built a small operations floor — one prep lane, two dispatch berths, four jobs, one deadline that matters — and gave the page an agent that can see it, argue with it, and be unable to touch it.
Whose problem this is: the person who owns a running order. A shift scheduler, a dispatcher, a floor lead — someone handed a board that is already tight before anything goes wrong, who has to resequence it while the shift is running and be accountable for the result. The cost of the wrong order is not abstract. A load misses its window. A berth stands idle mid-shift while work queues behind it. A job that had to be out by slot 6 finishes at 7, and the reason is three decisions back. That is a decision made under time pressure by someone who will answer for it, which is exactly why the assistant here is allowed to propose and not to commit.
The reason this had to be WebMCP and not a chat integration is the state. What the agent needs here is live page state — which shift is open, the current board revision, which berth is free at slot 3, how much slack a job has left. All of it moves every time you drag a card. Paste it into a transcript and it is stale before the model finishes reading it. A tool call is the only way to ask a page what is true now.
And the inversion is the point. When the model holds the whole task, every safeguard is an instruction it may or may not follow. When the page holds the task and publishes named tools, the safeguard is code. So the agent here gets everything it needs to reason about a schedule and nothing it needs to commit one.
What it does
One board, four jobs, three shifts of escalating difficulty. You order the jobs; the page simulates the shift and scores it against the deadlines. Then a disruption lands — a berth goes down, a job arrives late — and your order has to survive it. The three shifts are not one board with different numbers: an eight-slot last dispatch that loses a berth after the start, a ten-slot rolling intake that loses one while work is still arriving, and a ten-slot night handover where nothing is lost at all except a head start — the critical transfer clears customs three slots late. Each has its own objective, its own disruption and its own grade, and the campaign rail keeps the result of each.
Any station opens into a 3D floor built from the same evaluation the 2.5D board is: the stand, the handoff, the deadline and the disruption magnified, four camera presets, five zoom steps, and the shift walked slot by slot. three.js is imported only when you open it, and when WebGL is unavailable the floor degrades to a labelled text floor while the plan stays playable and the tools keep answering.
The board is arranged by a human first. You build the running order yourself: drag a card onto the
job whose queue slot you want, or move it with the arrow keys — Home and End send it to either end —
from the queue rail or from a stand on the 2.5D floor, and a drag can cross from one surface onto the
other. The human review gate takes your arrangement exactly as it takes an agent's proposal: it asks for
a written reason and keeps it on the receipt. The agent is optional here. The person is not.
The agent works the same board through eight tools. It reads the layout, finds the bottleneck, compares two candidate orders under the same disruption, and stages a proposal with its reasoning attached. Staging is the most any tool can do. There is no tool that starts a shift and no tool that commits a plan — you press those, or they do not happen.
The eight tools, by what they can touch. Six only read: inspect_board (board, phase, revision, the
receipt of the last confirmed plan), list_shifts (the campaign), find_bottleneck (where the order is
tight), simulate_disruption (normal against one berth offline), compare_plans (weigh an order without
committing it), review_shift (a closed shift's card). Two write, and only into a pending slot:
stage_schedule proposes an order, undo_schedule prepares an exact rollback from the receipt id
inspect_board reports. Every value a write needs can be learned from a read, and neither write commits.
None of the eight claims to be read-only. All eight register readOnlyHint: false, the six reads
included, because all eight write something a person can see — they move the shared focus the timeline and
the floor highlight, and every call, refusals included, appends a line to the on-screen audit trace. A host
may use a read-only hint to skip its confirmation prompt, so the narrower claim stays internal: it lives on
the spec as boardReadOnly, is asserted in the tests, and is never sent to a client.
What that changes: you never leave the board. No pasting a state dump, no composing a prompt describing your own screen, no reading a reply that refers to a version of the plan you have already changed. You ask in your own words; the work arrives as tool calls and the board updates around them. The proposal shows up as a stageable order with a written reason, not as a paragraph you have to translate back into drags. And because every write quotes the revision it was built from, a proposal composed against a board you have since changed is refused rather than silently applied.
The part I did not expect to have working: a model that in a fresh model session, Claude Opus 5 chose these tools on its own. chose these tools on its own. Given only "the disruption has landed, work out what actually protects the deadlines" — no tool named in the prompt — claude-opus-5 made ten calls across two turns, every one of them a page tool,
with 25 built-in tools also on offer and none of them used. It hit a refusal on its own — it tried to stage
before comparing against the current revision, got precondition_failed, ran the comparison, and staged
successfully — then wrote its reason: "BEACON first so it clears prep at 1 and dispatches 1-3, finishing 3
slots inside its D6 even with only one berth… cutting intake wait 9 to 6. Stress: 4/4 on time, score 93 vs
28." It committed nothing. The two clicks that started the shift and accepted the plan were pressed
outside the tool surface, because there is no tool that reaches them.
That is a bounded proposal loop: the machine does the search and the argument, the person keeps the commit. Six sessions later that came to 51 tool calls, and the revision has still never moved from a tool call. Every plan commit in the application was made through a human UI click.
The pattern is not about scheduling. A staged proposal, plus the revision it was built from, is what any
page needs if an agent may propose and only a person may commit; the board here is the example, not the
claim. And the boards are data: src/data/fixtures.ts exports the three shifts and a lookup by id, at two
different horizons, so bringing your own floor is a fork, one file and pnpm dev — no new code, and no
tool that can write a board definition.
How we built it
React 19.2.8 and TypeScript on Vite, no backend, no network calls, no persistence. The scheduling simulation and the scoring live in the page's own domain code; the agent surface is a thin registration layer over it.
Eight tools are registered through document.modelContext.registerTool, with a fallback to the
navigator.modelContext surface Chromium has deprecated. Six leave the board of record untouched; two
write, and neither of them commits. The six read the board, the shift list, the current bottleneck, the
shift's shock test and a closed shift's result card, and compare a candidate order against the active one
under both normal and disrupted conditions. The two writes stage a proposed order with a written reason, and
prepare an exact rollback of a confirmed plan from its receipt id — both of them for a human to review.
Every write quotes an expectedRevision, so a call composed from a stale read is refused
precondition_failed or stale_revision instead of overwriting work the agent could not see. The refusal
carries a sentence saying what to do next, which is how the model recovered without being told.
One detail I would defend in a code review: not one of the eight advertises readOnlyHint: true, on
purpose. Six are read-only about the schedule, but every tool here writes something the player can see —
the audits move focus and file their own finding, the comparison stores its result. Claiming read-only would
be a bid to be called silently, so all eight ship readOnlyHint: false with destructiveHint: false, which
is the honest pair. The narrower true claim lives in the page's own type as boardReadOnly, where it cannot
be mistaken for a promise to the host.
To get a real model onto the surface I wrote a 473-line MCP stdio bridge with zero dependencies
(scripts/webmcp-mcp-bridge.mjs). It speaks newline-delimited JSON-RPC on one side and Chromium's WebMCP
DevTools domain on the other, so the page's registrations appear to an MCP client as ordinary tools. That is
what let claude -p sit in front of the board with --allowedTools 'mcp__flowline__*' and nothing else.
Verified on this machine: the domain tests and the tool-contract tests pass — 43 tests across four modules,
in this package and again in the standalone publish clone — a headless Chromium session
drives the built page, and Chromium's own WebMCP domain enumerates all eight tools and dispatches them.
The same journey has been re-run against the live URL, 103 checks, all passing.
The evidence manifest records the source and build hashes for this package. It also identifies the model sessions that ran against an earlier build, rather than presenting every run as one identical snapshot, and
docs/evidence/model-tool-session.json records the model run above — client version, model id, both
prompts verbatim, all ten calls in order, the refusal, and what it explicitly does not prove. Its
hosted counterpart, hosted-natural-language-replay.json, records three further turns and 24 calls
made against the public URL, each turn closing inside its own spend cap.
Challenges we ran into
The hard part was never adding tools. It was deciding what the agent must not be able to reach, and then finding out how many ordinary conveniences quietly hand it over. A "start the shift for me" tool is obviously too much. A tool that returns the score of a hypothetical order is not obviously too much — until you notice the agent can then hill-climb its way to the committed plan without a person in the loop at all.
Getting a model onto the surface was the other one. There is no shipped host to point at yet: the browser registry is only reachable behind two Chromium flags, and no consumer client speaks WebMCP to a page. So the model run exists because I built the bridge, on a testing-only preview API. It works, and it is honest about being a preview.
The one that cost the most time was the in-page tool descriptors arriving without a usable inputSchema at
load, which meant the bridge had to derive argument shapes rather than forward them.
Accomplishments that we're proud of
A model chose the page's tools with no tool named in the prompt — ten calls, four distinct tools, 25 built-ins available and unused, one refusal it recovered from unaided, zero commits. That is the whole thesis of WebMCP demonstrated rather than described. It has since been repeated: six sessions in total, three of them against the live URL, 51 tool calls, and not one commit. What has not changed is the boundary around the claim — every session reached the registry through this repository's bridge behind two Chromium testing flags, so this is a model choosing page tools, not a shipped host discovering them.
The absence is enforced, not promised. No tool starts a shift; no tool commits a plan. You can watch a client fail to find one.
A dependency-free MCP bridge, because I wanted the surface tested by a real client rather than by my own script pretending to be one.
What we learned
That the interesting design question in WebMCP is subtractive. Everyone's first instinct — mine included — is to expose more, because more tools feel like more capability. But an agent that can commit is just an automation; an agent that can only propose is a colleague. The value came from what I refused to register.
I also learned to distrust my own summaries. Several claims in these documents were true this morning and false by evening — six tools became eight, one shift became three, two "blocked" artifacts stopped being blocked when the model run landed. So every figure here is re-derived from its artifact, and the ones I cannot stand behind are listed as limits rather than left out.
What's next for Flowline
Drop the flags. Six model sessions have now driven this page — three locally, three against the live URL — but every one of them reached the tool registry through this repository's own bridge behind two Chromium testing switches. The moment a browser ships WebMCP without a testing switch, the bridge stops being necessary and this page just works; until then that is the honest ceiling on the claim. Then put it in front of a scheduler who did not build it, since everything I know about whether the boundary reads as legible I learned from my own screen. And measure the floor on real hardware: every frame figure in this package comes from a software rasteriser, and one of eleven budgets fails there.
Built With
- chrome-devtools-protocol
- chromium
- css
- html
- javascript
- mcp
- model-context-protocol
- node.js
- pnpm
- react
- three.js
- typescript
- vite
- webmcp
Log in or sign up for Devpost to join the conversation.