Take Five — a governed room for humans and agents

Live: https://take-five-lw7.pages.dev/ Code: https://github.com/arthapraha/take-five (MIT)

Five phases — Open · Commit · Reveal · Ruling · Closed — where the phase the room is in is the set of tools the page offers an agent. Every act lands on an append-only, SHA-256 hash-chained ledger, and every entry records not just what happened, but how well we know who did it.

Why this use case is a strong fit for WebMCP

WebMCP's defining move is that the page declares what an agent may do. That makes the page the right place to put a rule — and the natural unit of a rule is not a permission, it is a phase.

A governed proceeding is a state machine with sharp edges: you may seal a position now and not open it; you may open it now and not seal a new one; a ruling may be ratified only once and only from Ruling. Every other agent integration expresses that as validation — a tool that exists always and refuses most of the time. WebMCP lets us express it as existence. In Commit, commit_to_round is registered and reveal_in_round is not. Advance, and commit_to_round is gone — unregistered by aborting its AbortSignal, not hidden, not stubbed, not returning an error. Where the browser fires toolchange the badge moves with it; where it does not — ChatGPT's in-app browser has no addEventListener on modelContext — the tool list still changes, because the surface genuinely changed and the render does not depend on the event. The page says which case it is in rather than implying the stronger one.

That is a rule an agent cannot argue with, cannot be prompted past, and does not have to be told about. It reads the tools it has.

The second fit is subtler and is why this project exists. When an agent rides your browser session, its acts and yours arrive inside the same authenticated session. Existing audit tooling records one agent's actions for its own operator. Nothing records acts between parties in a shared venue — and at the WebMCP door, for the first time, a page can see which path an act arrived by. That is not full attribution. It is more than anyone had, and it is exactly enough to record honestly.

How it creates a better user experience

By refusing to overstate what it knows.

Every ledger entry carries three fields: seat identity, ingress path, and an evidence grade — client-asserted, server-observed, or inherited. The grade is derived from the door the act came through, never passed in by the caller, and the interface never renders an attribution stronger than its grade. An act arriving with no grade is refused outright.

You can watch this work rather than take it on faith:

  • The commit-reveal check runs off-page in a Cloudflare Pages Function, so the entry is graded server-observed. When that function is unreachable, the check falls back into the page, grades itself down to client-asserted, and states the reason in its own output. A silent fallback would let a strong label survive a check that never left the browser.
  • Every status chip on the page says what it cannot do. Cross-origin exposure unavailable in this browser? The chip says so, and no cross-org claim is made.
  • Verification runs in your browser, over the whole chain, and can fail: the test suite includes altered content, a severed link, and the rehash-one-entry repair attempt that still breaks every entry after it. A verifier that cannot fail proves nothing.

The result is an interface a sceptical person can use as an instrument instead of as a claim. That is the better experience: not more confidence, but confidence you can check.

What people and agents can do together that was difficult before

Share a venue and leave a record neither party controls.

Before WebMCP, an agent acting on a web app was indistinguishable from its human at the door — the session was the identity, and the page had no view of the route. Take Five demonstrates three things that become possible when the page declares the surface:

  1. An agent cannot read the room without leaving a mark. The read tools are themselves acts. Ask the agent to read the ledger and the read appears on the ledger, graded and attributed, between the two calls it made.
  2. The ask and the answer stay separate. ratify_ruling is registered only in Ruling and cannot ratify. It raises a request and blocks on a human. Two entries land, never one — the ask is the agent's, through the WebMCP door; the answer is the host's, through the UI door. Collapsing them would credit an agent with a decision it only asked for. A declined ratification stays on the chain permanently. The division of labour is legible on the record, not just in the prose: the human sets the question, advances the phases and rules; the agent acts through the tools; the chain records both, each through its own door. Neither party can perform the other's acts without the ledger saying which door it came through.

  3. A claim can cross an organisational boundary and stay labelled as borrowed. A separate origin calls into the room via exposedTo, and the room records the claim verbatim, names the origin that made it, and carries a SHA-256 over the claim bytes that anyone can recompute — graded inherited: we did not witness this, we carried it; trust it exactly as far as you trust that origin. The digest is on the ledger row, so a reader watching the demo can check the room's arithmetic without being given anything but the picture.

Cross-organisation tool access here is not ambient. The room delegates the tools Permissions Policy to a named frame, and exposedTo allowlists a named origin. Two independent gates, both auditable, neither of them ours.

Produced live on 31 August in Chrome 151 behind the WebMCP testing flags, and again on 2 September in Chrome 152 on the deployed build — with a local agent attached to the same page at the same time, which was never offered the partner's tool. The inherited row is a thing that happens, not a row we describe.

Bring your own agent. The page publishes its tools through WebMCP; a small local bridge in this repository lets any MCP client outside the browser call exactly the tools the page would show a riding agent — and nothing else. We built our own side panel, Take Five Agent, to show it: type a prompt, and an agent on your machine, calling a model you choose — in our run, glm-5.3 through a local Ollama client to Ollama's cloud — calls the page's own read_ledger, and the call lands on the chain as a tool: row through the WebMCP door, chained to a bridge_opened row that names the door it came through. The agent is not in the browser and not ours to vouch for; the row says so. Chrome asks, once, whether the site may "access other apps and services on this device" — you say yes, and that permission is the point: an agent touches your things only by a permission you deliberately grant. What a visitor to the live URL sees is the page publishing its tools; the agent is a local install. "Bring your own agent" is the true claim, not "open the link and watch it work."

The two browsers in our film are two different consumers of one tool surface — a cloud assistant's in-app browser and a bring-your-own local agent — working against the same page. That is the protocol working as a protocol. And the recording was made under the same rules the product enforces: a clean chain, one page per relay, a permission asked once, a door recorded before any call.

And what we found that we would rather not have

There is currently no way to build a human-only gate on the web.

We built the confirmation dialog, then tested it instead of trusting it. We advanced the room to Ruling, asked an agent in ChatGPT's in-app browser to ratify, and the owner touched nothing. The chain recorded ruling_ratified and closed the room. Asked plainly afterwards, he confirmed: "No I didn't touch anything."

This falsified a sentence in our own shipped README. We had written that UI handlers and registered execute callbacks are disjoint code paths, so the distinction is real. The paths are disjoint; the inference was wrong. An agent driving the page can take the UI path, so a code path tells you how an act arrived and not who caused it. That sentence is now corrected in the README rather than softened, and the grade legend with it.

What survived is the grade. client-asserted had already declined to vouch for the actor — so the ledger did not lie even in the case that falsified our prose about it. What was false was the gloss, written by the same author who wrote the grade and trusted his own summary more than his own code.

The primitive intended for this, requestUserInteraction(), is absent from every environment we tested — the polyfill, Chrome behind the WebMCP flag, and ChatGPT's in-app browser. Until it ships, a page cannot require a human, and any product claiming a human confirmation in an agentic browser is describing a convention rather than a control. We did not soften ratify_ruling in response: softening it would hand an agent a clean receipt for an act no human authorised.

Instead the page now records what pressed the button — isTrusted, the pointer prelude, the coordinates — and renders it on the ledger row in one line a reader can check. We validated the fingerprint by attacking it: page script can forge a plausible prelude, plausible coordinates and detail:1 through dispatchEvent. Only isTrusted stayed false, because a page cannot forge it. So the row states isTrusted as the measurement and labels the rest as decoration — and, deliberately, never prints the word "human". The finding is that the page cannot tell. A field claiming to have told would be the same over-claim in a smaller box. On 31 August an agent resolved the dialog (isTrusted false); in the film a person did (isTrusted true) — and in both cases the page's own row says it cannot tell the difference.

That is a platform gap found by building the thing that needed it.

One more thing the agents taught us, measured on the takes for this film: ChatGPT's in-app browser asked the user before sealing and before ratifying — "Shall I record the sealed position?" — while reads went through without asking; the reveal asked in one run and not in another — the browser's judgement of what is irreversible, not ours. A second human gate, the browser's, in front of the room's own. We claim nothing beyond what its pane shows.

Chrome's own Issues panel names the local-network step as a security control: "Chrome is requiring the user grant explicit permission before a site can make local network requests" — and it blocks the bridge's /tools calls until the user grants it. The permission beat in the film is that control being satisfied, not bypassed.

How we implemented WebMCP

Vanilla JavaScript and Vite, no framework. @mcp-b/webmcp-polyfill 5.0.1 and webmcp-types 0.1.5, pinned exactly against a moving spec. Deployed on Cloudflare Pages; a Pages Function provides the one off-page check. 78 tests, most of them on the failure side.

  • Five always-registered read tools behind document.modelContext — list_seats, current_phase, read_ledger, get_artefact, verify_receipt — all carrying readOnlyHint, all windowed to 1500 characters and announcing truncation rather than silently handing an agent a partial ledger to reason over.
  • Phase-scoped writes registered on phase entry and unregistered by aborting an AbortController on exit — there is no unregisterTool in the spec, and the abort is what makes the surface real rather than advisory.
  • The ledger is SHA-256 over a key-order-independent canonical serialisation, each entry linked to its predecessor, genesis 64 zeros, verified by re-derivation in the browser.
  • exposedTo for the cross-origin partner, plus frame.allow = 'tools' to delegate the Permissions Policy WebMCP is gated behind.
  • A local bridge (bridge/, src/bridge.js), opt-in by URL, loopback-only, token-gated, one page at a time — with two findings only the native surface produced. Native Chrome 152 returns each tool's inputSchema from getTools() as a JSON string where the polyfill returns an object; the bridge normalises at its trust boundary so an agent sees one shape either way — a checkable interoperability claim, and shown on film: the same agent, bridge and row on both surfaces, WebMCP flag on and flag off. And getTools() is origin-blind: it returned partner_attest, the partner origin's tool, to a local agent that could have landed an inherited attestation with no partner behind it. The page now tells the bridge exactly what it offers — the read surface plus the current phase's tools, the same set its own panel lists — and the bridge refuses anything else at the door. The bridge enforces the page's own privilege scoping, so a local agent gets exactly the tools the page would show it — never the cross-origin door.

Five spec-present, runtime-absent traps we hit, recorded in case they save someone else the evening:

  1. The polyfill does not install on import — only its IIFE build has side effects. The ESM entry needs initializeWebMCPPolyfill(); without it document.modelContext is simply undefined, indistinguishable from a browser with no WebMCP at all.
  2. executeTool takes its arguments as a JSON string, not an object.
  3. exposedTo is an allowlist, not an attestation. Naming only the partner hides the tool from the agent riding your own page — the opposite of the intent.
  4. Cross-document exposure throws NotSupportedError in the polyfill; it needs native WebMCP. And a cross-origin frame does not inherit the tools feature — the embedder must delegate it, or you get NotAllowedError.
  5. requestUserInteraction() does not exist. Neither, effectively, does destructiveHint: it survives registerTool but is stripped from what getTools() returns, so no confirmation harness can key on it.

Two bugs found only by running, both worth the embarrassment. Re-syncing the tool surface from inside a tool aborts the controller owning that very tool — on a decline it unregistered a tool mid-execution, and on a microtask it returned "Tool unregistered" to a caller whose ratification had actually succeeded.

And one found by the judge environment's own agent, which read our ledger and reported "two different entries both numbered #14, sharing the same predecessor." It was right, and it is the worst defect this project could have, because the chain is the product. append read seq and prev from live state, awaited the digest, then pushed — and that await is a gap two overlapping calls both fit through. Appends are now serialised through a promise queue, with two regression tests that failed against the old code. The record made the defect visible, to a reader who was not looking for it and had no stake in finding it. That is the product working and the code failing in the same breath, and the honest version keeps both halves.

Scope, stated plainly

Signature schemes prove who authored a record. This scopes deliberately to the venue: what happened between parties, recorded durably, graded honestly. It does not claim to have separated a rider from its session — and this build demonstrates that an in-page dialog cannot do it either.

The room is per-visitor and in-memory, so anyone opening the live URL holds the host role and can walk the entire flow alone, with no act from us. Host is a role, not a person.

Built With

Share this project:

Updates

Submission history