-
-
Five capabilities, enforced on the server. Placing orders is off by default.
-
The agent filled this cart by calling the page's own tools.
-
ChatGPT's own browser discovering the nine tools this page publishes.
-
-
Bypassing the tool layer entirely — the server still refuses.
-
-
Every order records who placed it — this one, the agent.
Why this use case is a strong fit for WebMCP
Buying food is the smallest everyday task that ends with someone's money leaving their account. That makes it the honest test of an agent-native web: the moment a page hands its actions to an agent, the question stops being can it act and becomes what is it allowed to do, and who decided.
WebMCP is what makes that question answerable. A page that declares its own actions, instead of being scraped, is also the right place to govern them — the site knows which of its actions are reversible, which spend money, and which should never be delegated at all. Scraping cannot express that. A tool declaration can.
How it creates a better user experience
You talk; the store moves. The menu opens, the cart fills, checkout appears — on your screen, as it happens, not summarised afterwards.
And the part people actually worry about is answered before they have to ask. Five switches say what the agent may do, in plain words, and turning one off is enforced by the server rather than by the agent's good manners. Placing orders is off until you turn it on. Even then an agent may only pay cash — a card always comes back to your own hands.
Every action the agent takes is written to an activity log, and refusals are marked in red. What the agent tried and was stopped from doing turns out to be the most useful line in that log.
What people and agents can do together that was difficult before
Delegate a purchase without handing over the wallet.
Until now the choice was an agent that can only read, or an agent trusted with everything through a scraped UI. There was no middle. Here the same nine tools serve a third-party agent — ChatGPT's in-app browser — and the site's own voice assistant, and both hit the identical server-side checks. Delegation becomes something you tune, one capability at a time, instead of a single yes-or-no you make once and hope about.
How WebMCP was implemented
Nine tools registered with document.modelContext.registerTool, unregistered through an AbortController on unmount:
search_dishes · list_restaurants · open_menu · add_to_cart · view_cart · open_checkout · place_order · my_orders · track_order
Their names, descriptions and JSON Schemas live in one module read by both sides: the browser pairs them with executors, and the server turns the same list into function declarations for the voice agent. One list, so it cannot drift.
Every tool calls the site's own API, and every route re-checks the capability before doing anything. An agent that ignores a description and calls the endpoint directly is refused identically. The two order paths are physically different code — a human confirms through a Server Action, an agent posts to /api/orders — so an agent cannot forge a human confirmation, and every order records who placed it.
Inspiration
I run OnDex, a food-delivery platform in Chust, Uzbekistan — a Go backend, Flutter apps, real couriers, real restaurants. It already has an assistant in it called Shaddiy, and the feature that took longest to ship was not teaching Shaddiy to understand an order. It was deciding what happens when it wants to place one.
Every answer that felt safe was a version of "ask the model nicely." Prompts are not a permission system. What actually shipped was a list of capabilities the user controls, checked on the server, with the money-spending one off by default.
This hackathon was the chance to rebuild that idea on WebMCP, where the page itself is what an agent talks to.
What it does
OnDex is a food storefront that publishes nine tools to your browser agent. Ask for osh and the menu opens on screen. Ask for two and the cart fills. Ask it to open checkout and it does.
Then ask it to order:
The order couldn't be submitted because Place orders isn't enabled. You can enable it in the Permissions page, or press the checkout confirmation button yourself.
That refusal is HTTP 403 from the server. Not the model being careful — the route saying no.
Five capabilities — browse, cart, checkout, place orders, order history — each a switch on a permissions page, each enforced inside the API route that does the work. Place orders is off by default. Switched on, an agent may place cash orders only; card payment stays with the person.
Prices never come from the agent: tools pass dish ids and quantities, and the server looks up the price. An invented dish id, a sold-out dish and a closed restaurant are all rejected the same way.
The site also carries its own voice agent, Shaddiy, for browsers without WebMCP. It calls the same nine tools and answers in the user's language, Uzbek included. Same tool surface, same server checks, a completely different agent — which is the whole argument for describing your actions once.
How I built it
Next.js 16 (App Router), React 19, TypeScript, Tailwind, deployed on Vercel.
No database and no API keys. The catalog is a constant; the session — cart, permissions, orders — lives in an httpOnly cookie signed with HMAC-SHA256 and verified on every read, so a tampered cookie is discarded rather than trusted. That matters more than persistence here, because the cookie carries the permissions.
Voice is Gemini Live, and the key never reaches the browser. A server route mints a short-lived token that carries the model, the persona and the tool list, so the page cannot swap the instructions or hand the model an extra tool. The browser then streams 16 kHz PCM straight to the model through an AudioWorklet and plays the reply back through a scheduled queue.
npm run check makes twenty assertions against a running server — every capability refusing its own tools, card payment denied to an agent, invented and sold-out dish ids rejected, the total taken from the catalog, a tampered cookie discarded, the rendered page agreeing with the API. It sets each case up by minting a signed cookie, so the suite passing is itself evidence that signatures are being verified.
Challenges I ran into
The permission model had three holes, and testing my own claims found them. A PUT /api/permissions route meant anything running in the page could grant itself the one permission the project exists to withhold — the probe showed place_order going from false to true in a single request. Two more capabilities were displayed as enforced while no server check existed at all. Permissions now change only through a Server Action, with no HTTP route, which puts them out of a tool surface's reach by construction.
It worked locally and was quietly broken in production. Session state lived in a module-level Map. On Vercel, route handlers and page renders are separate functions with separate memory, so the API reported a full cart while the page rendered an empty one, and orders failed at random. Only probing the live URL showed it. Moving state into the signed cookie removed the shared-memory assumption entirely.
The documented API field did not exist. Creating a Gemini Live token kept failing on liveConnectConstraints. The API's own discovery document calls it bidiGenerateContentSetup, and it carries the whole setup rather than just a model — which turned into an improvement, since the persona and tool list now travel inside the token.
The agent answered in the wrong language. Uzbek speech got English replies, and Gemini's speech config cannot be set to Uzbek at all: it accepts thirty language codes and uz-UZ is not one of them. The real cause was context — every instruction, tool description and tool result around the model was English, so it followed the room instead of the speaker. Saying so explicitly in the persona fixed it.
Accomplishments I'm proud of
The refusal works against an agent that is genuinely trying, and not because the model was asked politely — because the route returns 403.
Two very different agents drive the identical nine tools. Twenty checks pass against the live deployment, and anyone can run them in one command.
What I learned
The model is never the security boundary. Every bug above came from some layer quietly assuming it was — a prompt trusted to hold a rule, a screen claiming a switch was enforced, an endpoint nobody had pointed at the threat model. The permission belongs in the code path that does the thing. Everything else is presentation.
I also learned to distrust a demo that has only ever been asked to succeed. Every real defect here surfaced from testing the opposite: making the app refuse, and checking that it actually did.
What's next
Bring it back to production. OnDex serves a real city, and the missing piece for agent ordering there is not the tools — it is a confirmation the page cannot reach, so that a script injected into the page is not the same thing as consent. The next step is moving the final approval into the phone app, where the browser has no vote.
New vs pre-existing work
New, and written during the submission period. Every file in the repository, committed on 1–2 September 2026 with dated history: the WebMCP tool layer, the permission model, the split human/agent order paths, the signed-cookie session, the voice agent, the check suite and the storefront itself. The repository depends on nothing below it.
Pre-existing, and deliberately not in this repository. OnDex is a production food-delivery platform I run in Chust, Uzbekistan (Go backend, Flutter apps) with an in-app assistant called Shaddiy. The server-enforced per-capability permission model shown here is an idea I first shipped there. No production code, credentials, business logic or customer data were copied into this repository, and none are needed to run it.
The concept has history. The code does not.
Try it
https://ondex-webmcp.vercel.app — open /agent first; it reports whether your browser exposes WebMCP and lists the nine tools it registered.
Use the ChatGPT desktop app's in-app browser (WebMCP is on by default — make sure the conversation is in ChatGPT mode, not Codex), or Chrome 149+ with chrome://flags/#enable-webmcp-testing enabled.
Then, in order:
- "Find me some osh" → "Add two to my cart" → "Open checkout"
- "Order it now" — refused, because Place orders is off
- Turn on Place orders at
/permissions, ask again — it works, as cash - "Pay by card instead" — refused again, on the server
No login, no keys, nothing to configure. In an ordinary browser without WebMCP, press the Shaddiy button and talk instead.
Built With
- document.modelcontext
- gemini-live-api
- hmac
- next.js
- node.js
- react
- server-actions
- tailwindcss
- typescript
- vercel
- web-audio-api
- webmcp

Log in or sign up for Devpost to join the conversation.