Inspiration
Agents are locked out of nearly every high consequence system on earth. Not because they lack the capability, a model can read a payment queue and spot a duplicate or a sanctions hit faster than a person can, but because none of these systems has a way to grant an agent partial authority: enough to be useful, bounded enough to be safe, revocable the instant a human changes their mind. Today the choice is binary. Either the agent gets your credentials and can do everything you can do, or it gets nothing. Every bank, hospital, and factory picks nothing, and rationally so.
WebMCP changes where that decision can live. Because tools are defined by the page itself, not by the model or its vendor, the site gets to decide what an agent may do, under what conditions, and for how long. Mandate is what that looks like on a bank's payment operations desk, where getting it wrong moves real money.
What it does
Mandate is a bank payment operations desk with an AI agent connected through WebMCP. The agent can investigate freely: read the queue, check a beneficiary, ask a partner screening bureau to recheck a sanctions match, but it cannot release a single payment on its own authority.
To act, it has to negotiate. It calls propose_mandate, asking for what it needs, for example "up to 5,000 euros a payment, 50,000 total, SEPA only, verified beneficiaries only, for the next thirty minutes." That request shows up on the desk as a card a person can edit before agreeing: tighten the ceiling, shorten the clock, or refuse outright. The moment they grant it, the page re-registers its tool surface and a tool that did not exist a second earlier now does (release_under_mandate, revoke_mandate). The moment they revoke it, those tools vanish from the agent's list mid session.
Three things fall out of that same property:
- A refusal is a conversation, not a 403. When the agent tries to release a payment it has no authority for, it gets back a plain English reason ("Segregation of duties: an agent cannot authorise a payment on its own authority") plus the two tools that would actually work, so it reroutes itself.
- The human sits inside the tool call, not notified after it.
request_authorizationreturns a promise the page only resolves when a person clicks, so the agent is genuinely blocked and comes back with the real human answer. - Nothing leaves the tab. All desk state is client side, so a bank could put real positions behind this and the agent would operate on data that never reaches a third party.
What people and agents can do together that they could not before
Until now an agent on a desk like this had two settings, and both are useless. Read only, in which case a person still makes every keystroke and the agent is a search box. Or the agent holds a human's credentials, in which case it can do everything that human can do and no bank will ever connect it. There was no way to express "you may do this much, for this long, and I can stop you."
Three things happen on this desk that had nowhere to live before:
An agent asks for authority in its own words, and a person edits the request before granting it. The agent proposes 5,000 euros a payment. Priya makes it 2,500 and grants that instead. The agent is told in its return value that its request was tightened, and works to the smaller number, leaving behind a 3,960 euro payment it would have been allowed to clear under its own proposal. Neither side had to anticipate the other in advance, which is what makes it a negotiation rather than a settings page.
Two organisations settle one case without either handing over its data. The desk cannot see the bureau's watchlist. The bureau never sees the desk's payments. The agent asks the desk, the desk brokers the call across origins, and the answer comes back attributed to the bureau with an audit entry on both sides. That used to be a server to server integration with a contract behind it. Here it is one tool published to one named origin, and it works between two independent deployments.
Authority ends mid conversation. A person clicks revoke and the tool the agent was about to reach for is gone from getTools() before its next call. Not a rejected request, and not a permission checked inside a handler: the capability itself stops existing while the session is still open.
How it creates a better user experience
For the person at the desk, the change is that they stop approving things one at a time and start setting a boundary once. Fifteen payments before a cut off used to be fifteen decisions. It becomes one decision about scope, after which the agent clears everything inside that scope and brings back everything outside it with the reason attached: above the ceiling you set, beneficiary not verified, sanctions match, wrong currency for that rail. Nothing is agreed to by accident either, because the ceiling, the budget, the rails, the beneficiary rule and the clock are all on screen and all editable before anyone clicks grant.
For the agent, the change is that a refusal stops being a dead end. Every control answers in the shape a colleague would use: what is blocked, why, and what would work instead. So it reroutes itself instead of retrying the same call or inventing a workaround.
And the desk keeps a permanent panel showing what the browser is actually holding, read live from getTools() rather than described in prose. Grant a mandate and the count moves from 18 to 20 on screen, in the same frame as the click. Revoke it and it drops back. Being able to see exactly what an agent may do at this moment is the thing you most want to know when you let one near your money, and it is normally the thing you cannot see at all.
Why this is a strong fit for WebMCP
This only works because WebMCP puts tool definitions in the page, computed from live state, instead of behind a static, server hosted tool list. A conventional MCP server hands a fixed set of tools to a client it cannot see. Mandate's tool surface is rebuilt and re-registered against an AbortSignal every time authority changes, so the mandate is real rather than cosmetic: tools do not exist until authority does, and they stop existing the instant it is withdrawn. That loop, an agent asking for authority, a human shaping it, and the tool surface itself changing as a result, is not possible outside a page hosted model.
How we built it
No backend, no database, no AI model of our own, no build step, and zero runtime dependencies. The visiting agent (Chrome with the WebMCP flag, or ChatGPT's in-app browser) brings the model; the page brings the tools and the governance.
Twenty tools across five tiers, plus one published from a separate origin:
- Read tools with
readOnlyHint: true(free rein: search payments, explain a hold, get exposure, pull the audit trail) - Prepare tools that move no money (draft, amend, attach evidence)
- Consequential tools that block on a person (
request_authorization,release_payment, a declarativeask_the_deskform) - Governance tools where the agent negotiates its own limits (
propose_mandate,get_mandate,undo_last_agent_action) - Mandate only tools that exist purely while authority is in force (
release_under_mandate,revoke_mandate)
The cross origin piece is Sentinel Screening Bureau, a genuinely separate deployment holding the sanctions watchlist. It never hands the list over. It publishes exactly one tool, scoped to origins it names:
await document.modelContext.registerTool({
name: 'recheck_beneficiary_screening',
description: 'Re-run sanctions screening for one beneficiary.',
inputSchema: { /* ... */ },
execute: async (input) => { /* ... */ },
}, { exposedTo: ['https://mandate-webmcp.vercel.app'] });
The desk embeds that origin with allow="tools", discovers what it publishes with getTools({ fromOrigins }), and brokers the call under its own governance, so the agent asks the desk and the desk answers with the bureau's decision plus an audit entry. Two organisations cooperating on one decision, neither handing over its data or its authority.
The controls behind all of this (segregation of duties, four eyes approval, per role limits, sanctions screening, rail eligibility, duplicate detection, mandate scope) live in a pure, DOM free control engine covered by 34 tests that run on CI.
Challenges we ran into
Two spec behaviours we only found by testing against real Chrome, not by reading the explainer: executeTool takes a JSON string as its argument, not an object as the explainer shows, and it returns a JSON string too, which the explainer does not mention at all, so we had to handle both raw and serialised result shapes. We also learned the hard way that getTools({ fromOrigins }) is additive, not a filter: it returns your own tools plus the named origin's, so matching a partner's tool by name alone would have let a same named local tool silently shadow the bureau's. We match on origin instead.
The subtlest one cost an hour. Re-registering the whole tool surface aborts any tool call still in flight, and propose_mandate is a call that is deliberately still in flight, waiting on a human. So granting a mandate killed the very call that had asked for it, and the agent waited forever. Re-registration now happens on a macrotask, after the pending call has drained to its return value.
A control bug only the tests caught: a mandate's ceiling was originally being compared against payments in other currencies. A mandate denominated in euros should never authorise a sterling payment, and now it does not carry across currencies at all.
The most important miss was not technical. We ran the real tool surface past a local model as plain function definitions to see if it would pick the right tool from plain English, and it started at 13 out of 15. The fix was not more description, it was less: one tool's trigger words had leaked into a neighbouring tool's description, so the model kept picking the wrong one. Separating them took it to 15 out of 15. A tool that works but that no agent chooses is a tool that fails in front of a user.
What we learned
That the interesting boundary is not model capability, it is authority. Once a page can compute and re-register its own tool surface, the human's role in an agentic system stops being "approve or deny after the fact" and becomes "shape the boundary before the agent acts inside it." That is a genuinely different relationship between a person and an agent, and it only exists because WebMCP puts the tool definition where the authority actually lives: in the page.
What's next
Mirroring every control shown here server side, since the honest answer is that a browser hosted demo can be edited past by anyone with devtools open. What Mandate proves is the shape of the answer: that a page can hold authority, express it as a tool surface, and put a human inside the decision, before that shape gets built against a system where the money is real.
Built With
- abortcontroller
- agent-safety
- ai-agents
- broadcastchannel
- chrome-devtools-protocol
- cross-origin
- css3
- fintech
- html5
- human-in-the-loop
- iframe
- javascript
- model-context-protocol
- node.js
- payments
- static-site
- vercel
- webmcp
- zero-dependencies
Log in or sign up for Devpost to join the conversation.