Inspiration

Two things stop an AI agent being useful inside a real business, and they pull in opposite directions.

It cannot see your data. Revenue, customers, orders — none of it can be pasted into a chat window, so the analysis that would actually help never happens.

And it should not be clicking around your admin panel. An agent guessing its way through a UI is slow, brittle, and occasionally catastrophic. Nobody wants to find out it changed the wrong price because it inferred which button said "Save".

WebMCP resolves both at once. The page declares exactly what an agent may do, so it never has to guess. And because the tools run inside the page, the data can stay in the browser: the agent asks questions of it without ever receiving it.

What it does

Enclave is the back-office of Northwind Trading Co., a fictional store. Its orders and catalogue load into the browser tab and stop there. Overview, Orders, Products and Analytics are all real views over that data — there is no server.

An agent — ChatGPT over WebMCP, or a built-in panel using your own OpenAI key — operates the store through 22 registered tools. It investigates freely. When it wants to change something, it proposes and waits for you.

The story the demo store carries

The data has one real problem seeded in it, and the whole route to the fix is discoverable without the agent ever seeing a row. Asked "EMEA fell in March. Find the cause and fix it," GPT-5 walks it end to end:

  1. It notices. Revenue by region, month by month — EMEA falls 52% in March 2025 and no other region moves.
  2. It finds the cause. Broken down by supplier, one name is missing from March entirely: Aurora Supply sold nothing in EMEA that month.
  3. It checks the shelf. All four Aurora products are still at zero stock, below their reorder level. The revenue hole and the empty shelf are the same fact.
  4. You decide. It proposes restock orders with its reasoning, and stops.

Sixteen tool rounds. No failed calls. About 7 KB left the browser. Zero rows uploaded.

Nothing changes without you

Reading is free. Anything that touches the business — a restock order, a price — is proposed:

tool.execute()
  ├─ validate the product and the quantity
  ├─ await requestApproval({...})   ←── the call blocks here
  │
  │    an approval card appears carrying the agent's own reason.
  │    "The agent is waiting. Nothing has changed yet."
  │
  │    … the operator approves …
  │
  ├─ raise the restock order
  └─ "Approved. RS-1042 raised for 900 × Nimbus Charging Dock."

Decline it, or take too long, and the tool reports back honestly that nothing happened.

That pause is the product. "The agent is supervised" is a policy; a tool call still blocked while a card says nothing has changed yet is a mechanism.

How it works

             ┌─────────────── Enclave (browser tab) ───────────────┐
             │                                                     │
  ChatGPT ───┼──→ document.modelContext ──┐                        │
             │                            ├──→ TOOL REGISTRY       │
  Built-in ──┼──→ OpenAI Responses API ───┘    (22 tools, one      │
  panel      │    (your own key)                definition site)   │
             │                                       │             │
             │                        ┌──────────────┴───────────┐ │
             │                        ▼                          ▼ │
             │                 approval gate            DuckDB-WASM │
             │                 (writes only)            store data  │
             └─────────────────────────────────────────────────────┘
                    no server · data never leaves the tab

One registry, two runtimes. Tools are defined once. One adapter registers them with document.modelContext; another exposes the same list to the OpenAI Responses API. Identical capabilities and identical approval gates either way.

The tool surface reads as well as it writes. Most only let an agent act. Enclave's lets it read the current state of the data, the analysis board and the catalogue — which is what lets it revise work it did not do itself.

The global filter is a view swap. Analytics cards read from a view, not a table. set_global_filter runs one CREATE OR REPLACE VIEW and every card on the board re-renders against the filtered data.

Every result is budgeted. Tool output passes through a single cap — 50 rows and 4 KB, adjustable in settings — before it can reach an agent. Raw row dumps are refused with a message steering the agent toward aggregation. The Activity Log counts the bytes on screen the whole time, so the privacy claim is metered rather than promised.

How we built it

Vite, React, TypeScript, Tailwind, Zustand, DuckDB-WASM and ECharts. No backend — the only server-side artefact in the repository is a vercel.json that sets one header.

142 tests cover the data layer, the store, all 22 tools, the approval gate and both runtime adapters. Two scripts in the repo are receipts a judge can run: verify-native-webmcp.mjs proves a real WebMCP browser discovers and executes the tools on the deployed URL, and live-agent-check.mjs measures a full agent run against the real API.

Challenges we ran into

DuckDB-WASM versus WebMCP's headers. DuckDB's threaded coi bundle needs cross-origin isolation, which conflicted with how the page had to be served. It turned out selectBundle() only picks coi when the page is already cross-origin isolated — so by deliberately not setting COOP/COEP we get the eh bundle, which needs neither, alongside the Origin-Agent-Cluster: ?1 that WebMCP does require.

Arrow's numbers are not numbers. DuckDB returns sum() over integers as DECIMAL(38,0), which Arrow hands back as an object, and count() as a BigInt. Both had to be flattened before anything downstream could read them.

An agent will happily spend its entire budget exploring. The first live run against GPT-5 used all ten tool rounds profiling columns and reached no conclusion. Raising the ceiling to thirty just moved the wall — it then made nine get_product calls, one product at a time, and still never proposed a fix. The fix was direction, not budget: tell the agent to group several dimensions in one query, to use list_low_stock instead of get_product per item, and to propose the fix as soon as the cause is established. The same investigation now finishes in 16 rounds with no failed calls.

A race we only found in a screenshot. React StrictMode runs effects twice, and two concurrent bootstraps collided on the same DROP/CREATE. Bootstrap is now single-flight per engine, with a test that calls it three times at once.

The native API is string-based. executeTool takes its arguments as a JSON string and returns the result envelope as one; passing a plain object fails with "Failed to parse input arguments". inputSchema comes back as a string too.

What we learned

A good tool surface is mostly good error messages. Name a column that does not exist and the tool lists the ones that do. Give an ambiguous product name and it returns the candidates and asks for a product_id. Ask for a chart whose y column is missing from the query result and it names the columns the query actually returns. That did more for reliability than any prompt change — the agent recovers by itself instead of stalling.

Context hygiene and privacy turned out to be the same mechanism. The 50-row / 4 KB cap started as a way to keep the agent's context small. It ended up being the thing that makes the privacy guarantee auditable.

Accomplishments that we're proud of

The approval gate, because it is a mechanism rather than a claim. And the byte counter, for the same reason: a figure a sceptical person can watch tick upward and check.

What's next

Multi-store tenancy; approval policies that let an operator pre-authorise low-risk actions while holding the rest; sharing a saved board as a link so another operator's copy re-runs it against their own data — the analysis travels, the data never does.

Built With

  • duckdb-wasm
  • echarts
  • gpt-5
  • openai
  • playwright
  • react
  • sql
  • tailwindcss
  • typescript
  • vercel
  • vite
  • vitest
  • webassembly
  • webmcp
  • zustand
Share this project:

Updates

Submission history