Inspiration

WebMCP lets an AI agent call real functions inside a web page. That is a big step forward, and it creates a new problem.

Those calls never leave the browser tab. No firewall sees them. No API gateway sees them. No payment network sees them. Every security tool we have sits outside the page, and the agent is now inside it.

So today a shopper has two options. Give the agent everything, or give it nothing. Most people pick nothing.

The numbers say the same thing. About 77% of people already use AI to help them shop. About 75% will not let it complete the payment. People want the help. They do not want to hand over the card.

That gap is the whole product. We did not want to build another agent. We wanted to build the thing that makes it safe to let one finish the job.

What it does

PageCTRL is one script tag. It wraps every WebMCP tool on a page and puts a human back in control.

It patches document.modelContext.registerTool() at load. Every registered tool then runs inside a guarded pipeline:

  1. Validate the arguments against the tool's schema.
  2. Apply the policy: allow, ask a human, or deny.
  3. Check the rate limit for that tool.
  4. Check the quantity cap across the whole cart, not one call.
  5. Check the amount cap for a single action.
  6. Reserve the spend against the session budget, atomically.
  7. Ask the human, and wait.
  8. Run the tool under a timeout.
  9. Redact personal data from the result.
  10. Scan the result for injected instructions.
  11. Append the call to a SHA-256 hash-chained journey.

The policy has two tiers. The merchant sets a floor in code, and it is locked. The shopper can only make it tighter. A user setting can never loosen a merchant rule. That matters, because the shopper and the merchant do not trust each other, and neither should have to.

Payment is real. The shopper saves a card once through Stripe. After that the agent can pay inside the limits, and Stripe charges the card off-session. The page never sees the card number, so no agent tool can read it.

The approval itself is signed. When a human clicks Allow, a separate service on a different origin issues an Ed25519 grant bound to that exact order: the amount, the quote, the session, and the origin. The shop server refuses to charge without it. That service runs on infrastructure the merchant does not control, so a rogue script inside the merchant's own page cannot approve its own payment.

Two attacks are in the live demo, because both are real:

  • A seller's reply comes back carrying hidden instructions. PageCTRL flags it and tells the agent to treat the text as data, not as commands.
  • A third-party widget loads late and tries to replace the checkout tool. The guard refuses, because the tool surface was sealed when the page loaded.

When a block happens, the agent can ask why. It gets the real rule and the real limit back, and the same answer appears on the shopper's screen. Blocking an agent is easy. Blocking it so it can still finish the job is the hard part.

How we built it

The storefront is Next.js 16 with the App Router, TypeScript, and Tailwind v4, deployed on Vercel. It registers 13 tools and guards 14.

The SDK is public/pagecontrol.js. It is plain JavaScript with no dependencies, about 2,000 lines. It has to be, because a merchant should be able to add it to any page with one script tag, and because a build step is one more thing that can go wrong at the trust boundary.

The signing service is a small Node server on Render. It holds the Ed25519 private key, publishes the public key as a JWK, checks a bearer token and an origin allowlist, and signs one grant per approval. The domain sits on Cloudflare.

We split the deployment on purpose. The shop runs on Vercel with its own Stripe key. The signing key lives somewhere else. If both ran in the same place, the merchant could mint their own approvals, and the signature would prove nothing.

Payments use Stripe twice. A hosted Checkout session in setup mode saves the card. A PaymentIntent with off_session: true charges it later, which is the pattern Stripe built for a payment the customer approved and then walked away from.

We used Codex for most of the implementation and Claude to plan, review, and run the adversarial passes.

Challenges we ran into

Serverless has no memory. The first build kept approval state in a JavaScript Map. That works on one machine. On Vercel each request can land on a different instance, so the state vanished. We moved to signed cookies and signed quotes, and stopped assuming a server remembers anything.

Two clocks never agree. Grants expire in 60 seconds. Render and Vercel drift apart by a few seconds, and our 5-second tolerance rejected valid approvals. We widened it to 60 seconds, which the short lifetime still bounds.

A single space cost an hour. Every checkout failed with grant_mismatch. The signature was valid. The cause was a leading space in an environment variable pasted into a dashboard, so the issuer string never matched. We fixed it by trimming every environment read, and by logging which field mismatched and both values. The log found it in seconds once it existed.

Stripe has field limits. Our signed quote ID runs to about 364 characters. Stripe caps an idempotency key at 255. Every charge was rejected before it reached the card. We keyed on the quote's UUID instead.

Caps have to count the cart. Our first quantity cap limited one call to five items. The agent added five, then five again. The cap now counts what is already in the cart, so a limit of five means five.

The cart kept emptying itself. Loading saved receipts overwrote the basket with an empty one, and that step ran again on every remount. Hydration now carries the live basket forward.

Accomplishments that we're proud of

Two adversarial security reviews found 16 defects. All 16 are fixed, and each one has a regression test.

The guard is real, not a mock. 45 SDK tests and 6 service tests pass, covering native WebMCP, abort signals, approval binding, and call pairing.

The payment is a real Stripe charge, not a simulation.

The approval is cryptographically bound to one order, and a separate origin signs it. Remove the grant and the server returns 403. We check that.

And it works with a real agent. In ChatGPT's browser the badge reads Native, so these are real WebMCP calls, not a simulation of them.

What we learned

Guardrails are easy to write and hard to place. Every check we first put in the page had to move to the server, because anything an agent can reach, an agent can skip.

An error message is part of the product. Our first blocks just said no. The agent then retried the same thing. Explaining the rule and the limit turned a dead end into a next step.

Two parties do not trust each other, and a design has to say so out loud. The merchant does not trust the shopper's agent. The shopper does not trust the merchant's page. The two-tier policy and the third-party signature both exist because of that, and neither would exist if we had assumed good faith.

What's next for PageCTRL

  • Publish the SDK on npm and a CDN, with Subresource Integrity.
  • Add a <page-control-panel> custom element, so a merchant can place the UI.
  • Ship a hosted merchant console with real keys, usage, and dispute evidence.
  • Support the requestUserInteraction() approval hook when it lands in the WebMCP spec, and drop our own modal in favour of the browser's.
  • Export the hash-chained journey as signed evidence for chargebacks.
  • Extend past shopping. Booking, support actions, and account changes have the same problem and the same shape.

Built With

Share this project:

Updates