With WebMCP now behind a flag in Chrome, and agents that browse just around the corner, more and more people are trying to figure out how to let an AI agent use their website.
The browser's answer to that is WebMCP, and I think it's a good idea, but I also think it skipped a step along the way.
Before a page offers tools to AI agents, I'd argue that it should have a proper client side api that any code can call. Something that would:
- Allow other sites, browser extensions, or scripts on the same page to call it, with minimal effort.
- Allow transparently passing a function from one page to another, and allow it to be called back in the context of the original page.
- Give back a deterministic, structured, programmatic result.
This means if you get that right, the agent tools fall out for free. The opposite is also true. If you skip it, the agent tools become the de-facto api, whether or not they were designed to be one.
With all this in mind, I spent a couple of weeks hacking on something I've dubbed glottis. (The code is on GitHub, if you'd rather just read that.)
First, a story with no AI in it
Take a gift guide, like The Gift Edit. Its first pick is a record from Rising Sun Records, a shop you've bought from before: Taeko Ohnuki's Sunshower, the 1977 pressing. This is how it looks:

What happens is:
- The blog loads the shop in a hidden iframe, and asks it what it can do. So far so easy: it has two functions, one to add something to a cart, and one to tell you when the cart changes. The shop decides what's on that list, not the blog.
- The blog then calls the shop's
onCartChange, and passes in a function to call whenever the cart changes. The shop calls that function straight away with the initial cart state: 2 records, $165. - You click "Add to my Rising Sun cart". The blog calls the shop's
addToCartfunction. The shop then adds the record to your actual Rising Sun Records cart, in its own page. - The shop calls the blog's function again, and the pill updates itself: 3 records, $229.

You'll notice, neither you nor the blog ever had to:
- Leave the page, or open a new tab.
- Log in to the shop again.
- Set up an API key, or go through OAuth.
- Figure out how to find the product on the shop's site.
This is secure, too: only functions that the shop exports explicitly become available to the blog, and even then, none of the shop's page, or your account, or your password, or anything else is made available to the blog. All the blog can do is call the function and pass arguments back to the shop. It feels like the blog is just calling a regular promise-returning function on its own page, but actually it's calling a function on an entirely different site, on an entirely different domain.
There's no AI anywhere in that story. But an agent trying to buy you that record needs exactly the same thing: a function on the shop that it can call. And that's where WebMCP comes in.
Wait, what's WebMCP?
A regular MCP server lives behind someone's domain, somewhere on the internet. ChatGPT or Claude connects to it, logs in as you, and calls your tools over HTTP.
The principle behind WebMCP is as follows:
- You declare all of your tools in your web page, with
registerTool(): a name, a description, a schema and a function. - Any agent running in your browser reads that list of tools.
- Any tool the agent calls runs right there in your tab.
Why is this awesome? Because it embraces the idea that tools are just functions. They take arguments as input, and they output a result, right there in the page you already have open. Practically speaking, that brings a myriad of advantages to the table:
- Regular login rules apply; if you're signed in to the tab, the tool is signed in too. No separate auth, no API keys.
- Regular page rules apply; if it's on the page, the tool can see it, half-filled forms and all.
- Tool calls are just regular function calls, in the page you're looking at, so you can watch what the agent does, and the page can ask you first.
- If your front end already does the work, there's no new backend to build!
The difference between WebMCP and a regular MCP server is, instead of running on a server somewhere, WebMCP runs in the page. And that happens to be where most of the web's functionality already lives.
The cart before the horse
Think about how MCP servers have turned out on the server side. The best ones usually sit on top of an API that already existed. Stripe had an API for well over a decade before it had an MCP server, and the MCP server is a pretty thin layer over it. The API came first. The MCP server came after, and the agent is just one more client.
In the browser, we're starting from the other end. A web page has no formal API at all for other code to call. Say I want to put something in your cart on a shop's site, from another site, or an extension, or an agent. Usually the best you can do here is either:
a) Scrape the DOM, and hope the shop never ships a redesign
b) Invent a postMessage protocol, and hope the shop implements it too
Both of these are soul-crushing hacks.
WebMCP does give the page an API of sorts; it's just shaped for agents. You can call a page's tools from code with executeTool(). That's not a huge deal, it works, but it also still means I can't do more advanced things like passing across functions or callbacks, or holding on to a cart object between calls. JSON in, a string out.
If pages only ever expose MCP tools, this ends up going a bit like this: a shop ships some tools, then
- Someone needs to call the shop from code. The MCP tools are the only API there is, so a ton of code gets written against them.
- The shop decides to change a tool. A ton of code breaks.
- The shop stops changing its tools. Now the tools are the de-facto API, whether or not they were designed to be one.
- …
Notice a pattern here?
So: build the proper API first, and let the agent tools fall out of it. glottis is my attempt to do exactly that.
A page with an API
Here's the code behind the add-to-cart half of the story (the function that keeps the pill up to date comes next). Rising Sun Records serves its API:
// On rising-sun-records.example
import { serve } from 'glottis';
serve({
clients: {
remoteWindow: {
allowed: true,
domain: 'https://the-gift-edit.example'
}
},
exports: {
addToCart: ({ sku, quantity }) => {
// the shop's own cart code
return cart.add({ sku, quantity });
}
}
});
And the blog connects to it and calls it:
// On the-gift-edit.example
import { connect } from 'glottis';
const shop = await connect({
url: 'https://rising-sun-records.example/api'
});
await shop.exports.addToCart({ sku: 'sunshower-1977', quantity: 1 });
Notice that the blog didn't need to set up any kind of message listener, and the shop didn't need to agree on any message names. The blog just called addToCart (connect() opened the shop in a hidden iframe behind the scenes, waited for it to say hello, and checked its origin) and glottis did all of the required messaging for us!
The shop stays in charge, too. The clients block limits connections to the exact domain the shop expects them to come from, so I can sleep easy knowing that it's not possible for a rogue window to call these functions under any circumstance (and the shop can ask the visitor first, in its own UI, before it hands anything over).
Cookie support is limited, by the way; with third-party cookie restrictions becoming more and more common, inside the hidden iframe the shop starts out signed out. The Storage Access API goes some way to solving this problem: if the shop needs the reader's session, it can ask for storage access with one click.
Functions should cross the boundary
This is a part I care about a lot, and it shows up in a lot of my previous work, e.g. zoid and post-robot.
Normally when two pages talk, they pass data. And when they need to refer to something, they pass an ID: a cart ID, a subscription ID, a request ID. Then every message after that has to carry the ID along with it, which means persisting some kind of unique id and keeping a bunch of state, and generally exposing myself to all kinds of potential bugs and spaghetti code.
Inside a single page, I can just return an object with methods on it, or pass a callback. I want to be able to do exactly the same thing across the page boundary.
So let's give it a try, and grow Rising Sun Records' API a bit. Instead of a single addToCart, it hands back a cart object with methods on it. And it adds the onCartChange from the story, which takes a callback:
// On rising-sun-records.example
serve({
clients: {
remoteWindow: {
allowed: true,
domain: 'https://the-gift-edit.example'
}
},
exports: {
openCart: () => {
const cart = getCart();
return {
items: cart.items,
add: ({ sku, quantity }) => {
return cart.add({ sku, quantity });
},
checkout: () => {
return cart.checkout();
}
};
},
onCartChange: (notify) => {
const listener = getCart().listen(notify);
return () => {
listener.stop();
};
}
}
});
And the blog uses it like any other object:
// On the-gift-edit.example
const cart = await shop.exports.openCart();
await cart.add({ sku: 'sunshower-1977', quantity: 1 });
const stop = await shop.exports.onCartChange(({ count, total }) => {
pill.textContent = `${ count } items, $${ total }`;
});
// later, when the blog no longer cares
await stop();
There are no cart IDs anywhere. We didn't need to accept a cartId parameter, since we already had the cart in closure scope when we created the add function. The callback I pass to onCartChange is called in the context of my page, with whatever closure scope it has, every time the cart changes; that's what keeps the pill in the story up to date. And the function I get back stops that one specific listener, and nothing else.
I wrote about this idea back in 2021, when it was powering zoid and post-robot at PayPal, and my view hasnt changed. As it turns out, functions are the perfect pattern for "request and response" style interfaces. And sending small self-contained functions can actually limit potential misuse, since it is easier to make functions as specific and tightly-scoped as you like: a logout function bound to one user can only ever be used to log out that one specific user. A logout(userId) handler has to be very carefully guarded against everybody else's userId.
Who would actually call this?
Today, I think it's mostly two kinds of caller.
The first is a parent page talking to an iframe. Think of things like PayPal Checkout in an iframe, or a gift guide adding to your cart on a shop, or a travel site embedding an airline's seat picker. Popups are the same idea; for things like logging in or paying, where you really need a url bar to be present.
The second is a browser extension. Instead of scraping the DOM for each thing it wants to read, and breaking every time the site ships a redesign, an extension can just call the functions the page exposes, and the page handles the messy parts. A price tracker could just ask the shop for prices.
The possibilities beyond that are pretty endless, and a few ideas which spring to mind immediately are (none of which glottis does yet):
- Running
checkout()from a Playwright script, instead of clicking through four screens. - Two tabs of the same app, or a service worker, talking to each other.
- A native app, and the web page it embeds.
And of course, agents are on that list too.
Agents come for free
To let agents in, the shop needs to do two things: wrap the functions it wants agents to see in tool(), which adds a description and a schema, and turn on the webMcp client. Here's the first example again, with both:
// On rising-sun-records.example
import { serve, tool } from 'glottis';
import { z } from 'zod';
serve({
clients: {
remoteWindow: {
allowed: true,
domain: 'https://the-gift-edit.example'
},
webMcp: {
allowed: true
}
},
exports: {
addToCart: tool({
description: 'Add a product to the cart',
input: z.object({
sku: z.string(),
quantity: z.number().int().min(1)
}),
handler: ({ sku, quantity }) => {
return cart.add({ sku, quantity });
}
})
}
});
And we're done! addToCart is now a WebMCP tool, registered with the browser. It's also still a regular function for the blog, and the arguments are validated either way. Any function the shop doesn't wrap in tool() stays available to code, but agents never see it.
It's almost just like writing a regular function, without having to deal with a second list of tools to keep in sync with your "real" API.
Now for the fun (and experimental) bit.
In code, openCart() gives you back a cart with an add method on it. An agent can't hold on to an object. So MCP servers today usually hand it an ID instead. I create a cart, I get back a cart ID, and I have to pass that ID into every call after it. The problem is, the model has to copy that ID out of one result and paste it into the next call, character for character, and generally expose itself to all kinds of potential bugs: it drops a character, or mixes up two carts, or just makes one up.
So, when a tool returns an object with methods on it, glottis will actually take care of this for me! What happens is:
- glottis takes the cart object, and persists it locally in the page
- It then registers a brand new tool for each method on the object, already tied to that one cart:
openCart_1_add,openCart_1_checkout - When the model calls one of those, glottis looks up the original object, and calls the method on it
- Check out, and you get a tool for tracking that order, in exactly the same way
So the model never has to write an ID at all.

If you're familiar with HATEOAS, it's a very similar principle: every response comes with links for what you can do next. Most JSON APIs skipped that part, and for good reason, to an extent: a human writes the client once, and humans are pretty good at plumbing IDs around. A model is a different kind of client though. It reads every response fresh, and decides what to do next from it. So I'd argue MCP missed a trick by not borrowing the idea.
Be warned: it's experimental at the moment. It worked nicely when I pointed Claude at a little test shop, but that's about as far as I've taken it. I'd love to hear how it goes if you try it.
Why not just use ...?
A little googling shows up a bunch of projects which have done something similar in the past. These are all awesome, but none of them quite fit with what I was looking for, for a few reasons:
- Plain
postMessageworks, technically speaking. In fact, it's really the only way to message across two different domains, unless you start throwing servers into the mix. But I'd prefer not to hand-write request IDs, origin checks and timeouts every single time, and I still can't send a function. - Penpal is the closest in spirit; it does a handshake, then gives you methods. But arguments and results appear to be copied across as plain data, so there doesn't seem to be any way to send a callback, or get back an object with methods on it, and you're back to passing IDs. You also create and manage the iframe yourself.
- Comlink is great for Web Workers. But functions only make it across if you wrap each one in
Comlink.proxy(), which gets tedious fast for nested objects. Opening frames, handshakes and origin checks are all left as an exercise to the reader. - Cap'n Web is really impressive, with tricks like promise pipelining. But it asks you to explicitly dispose of every reference you're handed. This seems like the wrong default to me: in a browser api, most callers will forget. And like the others, frames and origins are your problem.
- MCP-B runs real MCP over
postMessage, so you write an MCP server inside your page. It's good work, but it's the MCP-first ordering again: code callers get JSON in and text out, with no live references. - WebMCP itself isn't something glottis replaces; it's where glottis plugs in. If it ships, glottis registers your tools there. If it doesn't, your page still has an API.
- post-robot and zoid are mine, and glottis actually runs on post-robot. The difference between zoid and glottis is, zoid is for components, where both sides share a definition and zoid renders the frame; glottis is for APIs, with a page that can run hidden.
Where it runs today
WebMCP is still a draft, and in Chrome it's behind a flag. Framing is limited, too; a page inside an iframe can only offer tools if every page above it grants the tools permission, and a lot of hosts (ChatGPT included) don't.
So right now there's absolutely no way for native WebMCP to reach a page inside a ChatGPT app. glottis can though, since it's just postMessage underneath. That's how my ChatGPT bridge works: a ChatGPT app opens a shop in a little in-chat browser, and the model calls the shop's tools through glottis.

ChatGPT adding a record to a shop's cart, through tools the shop serves with glottis.
glottis and the bridge are two halves of the same idea: give a page a proper API, and it can talk to whatever's on the other side, wether or not the browser has caught up yet. There's a lot more to the ChatGPT side than that, and it has its own post:
Try it
npm install glottis
Or drop in the script tag and use window.glottis:
<script src="https://unpkg.com/glottis@0/dist/glottis.min.js"></script>
Everything is on GitHub, and it's on npm as glottis. It's MIT licensed and very much 0.x, so expect things to change!
Give it a try! If you think the whole idea is wrong, let me know. I'm very happy to be proven wrong.
(if anyone working on WebMCP is reading: a way to hand an agent a function reference instead of a tool name would make bound tools a lot less of a hack. Just saying.)
