DEV Community

Toheeb Olanrewaju Olagoke
Toheeb Olanrewaju Olagoke

Posted on

Building Co-Shop: A Shared Cart for Humans and AI Agents with WebMCP

The problem with "agentic" web apps today

Ask most AI agents to shop for you online right now, and here's roughly what happens under the hood: the agent opens a headless browser, loads the page, tries to parse the DOM, guesses which is the "Add to Cart" one, clicks it, waits, re-parses, and hopes nothing changed in the layout since the model was trained on how that site "usually" looks.

It works sometimes. It's also slow, brittle, and completely opaque to the human sitting there. You ask an agent to "add a few vegetarian dinners to my cart," it goes away for a minute, and you get a summary back: "Done! I added 3 items." You had no idea what it was doing while it did it, and if it clicked the wrong thing, you find out after the fact.

WebMCP is a proposed web standard that removes the guesswork entirely. Instead of an agent reverse engineering your UI, your page tells the agent what it can do:

document.modelContext.registerTool({

name: "add_to_cart",

description: "Add a product to the cart",

inputSchema: {

type: "object",

properties: {

  productId: { type: "string" },

  quantity: { type: "number" },

},

required: ["productId"],

},

async execute({ productId, quantity }) {

// your actual cart logic

},

});

An agent visiting your page right now, in ChatGPT's WebMCP-enabled desktop browser, or in Chrome with the #enable-webmcp-testing flag can call add_to_cart directly. No clicking, no scraping, no guessing.

I wanted to see what this actually unlocks beyond "agent can now click buttons faster," so I built Co-Shop for OpenAI's WebMCP Challenge: a grocery storefront where a human and an AI agent share the literal same cart, live, in the same browser tab.

Here's what I learned building it.
The core idea: one state, two actors
The easy version of this project would have been "agent has its own cart-building tool, human has a UI, sync them somehow." I didn't want that. The whole point of WebMCP, I think, is that there doesn't need to be a sync step at all if the agent's tool and the human's UI both mutate the same piece of state, they're never out of sync in the first place.

So in Co-Shop, app/page.js is a single client component holding the cart in React state, and both the UI's click handlers and the WebMCP tools' execute functions call the exact same helper functions:

function addToCart(productId, quantity, actor) {

const product = PRODUCTS.find((p) => p.id === productId);

if (!product) return { ok: false, error: No product with id "${productId}". };

const qty = Math.max(1, Math.floor(quantity || 1));

setCart((prev) => {

const existing = prev.find((i) => i.productId === productId);

if (existing) {

  return prev.map((i) =>

    i.productId === productId ? { ...i, quantity: i.quantity + qty, addedBy: actor } : i

  );

}

return [...prev, { productId, quantity: qty, addedBy: actor }];

});

logActivity(actor, added ${qty}× ${product.emoji} ${product.name} to the cart);

return { ok: true, product: product.name, quantity: qty };

}

The only difference between a human clicking "Add" and an agent calling the add_to_cart tool is the actor string passed in: "human" vs "agent". That one parameter is what drives the purple "AGENT" badge, the pulse animation, and the activity feed entry. Everything else the state, the total, the render is identical.
Registering the tools
All seven tools are registered in a single useEffect, using an AbortController so cleanup is automatic if the component unmounts:

_useEffect(() => {

if (typeof window === "undefined" || !("modelContext" in document)) {

setWebmcpStatus("unsupported");

return;

}

const controller = new AbortController();

const opts = { signal: controller.signal };

async function registerAll() {

await document.modelContext.registerTool(

  {

    name: "search_products",

    description: "Search the Co-Shop grocery catalog...",

    inputSchema: {

      type: "object",

      properties: {

        query: { type: "string" },

        category: { type: "string" },

        tags: { type: "array", items: { type: "string" } },

        maxPrice: { type: "number" },

      },

    },

    async execute({ query, category, tags, maxPrice } = {}) {

      const results = findProducts({ query, category, tags, maxPrice });

      return { content: [{ type: "text", text: JSON.stringify({ count: results.length, results }) }] };

    },

  },

  opts

);

// ... six more tools registered the same way

setWebmcpStatus("ready");

}

registerAll();

return () => controller.abort(); // unregisters everything

}, []);_

Two details tripped me up here, and I think they're worth calling out for anyone building their first WebMCP tool:

  1. Stale closures. Tools are registered once, on mount. If your execute function captures cart directly from a useState value at registration time, it'll keep reading that original empty cart forever. React state updates don't retroactively update already-created closures. The fix is to route every read and write through setCart (the function, not the value) and derive snapshots on demand rather than trusting a captured variable.

  2. Return shape matters. WebMCP's execute functions are expected to return a content-array shape:

{ content: [{ type: "text", text: "..." }] }

I originally just returned plain objects. It technically "worked" in my own manual testing (since I controlled both ends), but once a real agent called the tools, structured JSON in that text field not a bare string is what let the agent actually reason over the result (e.g., knowing exactly which 5 of 7 requested dinners fit a budget, and why).
The tool that mattered most: compound actions
The single tools (add_to_cart, remove_from_cart, get_cart) are the "hello world" of WebMCP pretty much the exact example in OpenAI's own challenge brief. The one that actually made the demo interesting was a compound tool:

async execute({ days, dietary, avoidTags, maxBudget }) {

return textResult(planDinners({ days, dietary, avoidTags, maxBudget }));

}

plan_dinners takes a natural-language-shaped request "7 days, vegetarian, under $40" and internally does the filtering, picks distinct matching meals, and calls addToCart for each one, all in a single tool round-trip. This is the difference between an agent doing one thing at a time forever, and an agent actually executing a plan. When I tested it against ChatGPT's in-app browser, asking it to "plan a week of vegetarian dinners under $40" resulted in exactly this response, generated by the tool itself:

"Added 5 vegetarian dinners to the cart for $39.50. Seven don't fit under the $40 limit."

That sentence isn't the LLM improvising it's structured output straight from plan_dinners' return value, which the model then relayed. That's the part of WebMCP I find genuinely different from prompt-and-hope agent behavior: the agent isn't guessing what happened, it's told exactly what happened, in a format it can act on.
Testing against a real agent
Two ways to verify a WebMCP integration actually works, in increasing order of "does this prove anything real":

document.modelContext exists in the console. Proves the API surface is there. Doesn't prove your tools are being called correctly.
A stubbed registerTool in a headless test. I did this with Playwright before ever touching a real agent stub document.modelContext.registerTool to just store the tool object, then call .execute() directly in a script. This caught real bugs (a quantity-update edge case) with instant feedback, no agent round-trip needed.
An actual agent client. For me, that was Chrome with chrome://flags/#enable-webmcp-testing enabled (confirms registration my status pill flipped to "Agent tools live") and, more meaningfully, ChatGPT's desktop app, which has a built-in WebMCP-aware browser. Opening my deployed URL there and typing a plain-English request was the first time I saw an actual model decide, on its own, which tool to call and with what arguments.

That third step is the one I'd tell anyone building a WebMCP project not to skip, even under deadline pressure. Stubbed tests prove your code is correct. They don't prove an agent will actually choose to call your plan_dinners tool instead of, say, five separate add_to_cart calls, or prove your tool descriptions are clear enough for the model to pick the right one at all.
What I'd do differently
If I kept building this, the next thing I'd add is a compare_products tool right now an agent can search and filter, but can't ask "which of these is the better value per serving," which feels like the natural next compound action after plan_dinners. I'd also want to test with more than one agent client side-by-side, since tool-calling behavior (how eagerly a model reaches for a compound tool vs. chaining primitives) seems to vary meaningfully between clients.

Try it yourself
Live app: co-shop-gules.vercel.app
Source (MIT licensed): github.com/Olacode01/co-shop

If you have a WebMCP-enabled browser handy, open the live link and ask an agent to "plan a week of vegetarian dinners under $40." Watching your own cart fill up live, tagged by who added what, is a much better way to understand what WebMCP changes than reading about it.

Top comments (1)

Collapse
 
raknaos profile image
Baptiste Le Bouquin

The "one state, two actors" decision is the interesting one here, and I think you're right that it's the whole point of WebMCP rather than a convenience.

What I'm still unresolved on after running agents against real web UIs for months: the shared-state model assumes the tool call and the UI event are both well-behaved, in-order mutations of the same reducer. But agents fail differently from humans — a human doesn't call add_to_cart twice because they saw it work, while a model retrying across a timeout boundary will happily double-mutate an observable, live cart, and now the error is visible to the user mid-session instead of buried in an agent log. In your Co-Shop design, does an agent's failed-but-committed call get any undo semantics, or is the human expected to see and correct it? Because "live shared cart" turns every agent hallucination into a user-visible bug, which is honest but a very different reliability bar from "agent has its own draft cart you approve at the end."

The other angle: registering tools per page means the trust surface is per-page, and add_to_cart with a productId the agent invents is now a validation problem on your execute function, not just a UI guard. You already need the same server-side checks any API would need — which suggests WebMCP tools eventually want a capability handshake (what may this agent mutate in this session), or every storefront ends up trusting whatever DOM context it's standing in.

Genuinely curious how the demo behaved with a real model on it — did ChatGPT's browser call the tools cleanly, or did you see the retry-double-add pattern I'd expect? That failure mode will decide whether agentic checkout ships in year one or year three.