Inspiration
In August 2022 a third of Pakistan went under water. My family is from Sindh.
My uncle spent that month running between four places. A food store with rice and oil in it. A school that had become a shelter because it had a roof and a gate. A group of men with pickup trucks who would drive anywhere if you called them early enough. And a cold room, in the back of a pharmacy, holding the insulin for a whole district.
Four organisations. Four sets of records. None of them could see the others.
He held the entire thing together with a phone and a notebook. He wrote down who had what, who had asked for what, and who he had already promised it to. And he got things wrong, because anyone would. A family got sent to a shelter that had filled up an hour earlier. Boxes were double promised. Insulin sat in a cold room for two days because the person who needed to know it was there was on a different page of the notebook.
Nobody in that story needed better software for their own organisation. The pantry knew its own stock perfectly well. The shelter knew its own beds. What was missing was the seam. The part between them. The thing my uncle was doing by hand with a biro.
When I read the WebMCP spec, that is the gap I saw. Not another site publishing tools for itself, but the layer that lets a person and an agent work across four sites that will never, ever hand each other their data. Every organisation in that network had a reason to keep their own records. None of those reasons went away just because a flood arrived.
So Ferrule is the notebook, rebuilt as software, with the agent doing the running around and the human still holding the pen for anything that costs something real.
What it does
Ferrule is a dispatch desk. Open it and you are looking at one queue that reaches across five independent websites, each on its own origin, each with its own data in its own browser storage.
The four partner sites are real, usable sites. A volunteer can open the pantry and reserve a food box with no agent anywhere near it. What each one adds is a small set of WebMCP tools. Nineteen across the four of them.
The Desk publishes seven verbs of its own and nothing else. intake_request,
find_capacity, draft_plan, commit_plan, open_lease, close_lease,
read_ledger. That is the whole surface any agent gets. No partner tool is ever
callable directly, and there is no second path for a privileged caller, because
there is no privileged caller.
Here is what actually happens when the queue moves.
A lease opens. Time boxed, scoped to a purpose, and it names exactly which partner tools it permits. Only once that lease is granted does the partner's own site mount into the Desk as a frame. It was not on the page a second earlier. When the lease closes, expires, or is revoked, the frame comes down and the tools it carried leave with it.
The policy engine decides. Every hop is checked against a scope map before it travels. A refusal is a value with a sentence in it, never a thrown exception, so an agent reading the answer knows what happened and why.
The run stops for a person. commit_plan is the one verb in the system that
succeeds and goes nowhere. It answers with a status of awaiting_human_approval,
an approval id, and a line telling the caller outright that none of its verbs
continues from here. So a model reads that, reports the plan as raised, and stops,
instead of looping or treating it as an error. What the human sees is the target
origin, the tool, the arguments, the required scope, and a digest taken over the
substance of the plan. Rename the plan and the digest holds. Change the origin, the
tool, the arguments, or the order, and the approval is void.
Then the write lands, and the partner's own page updates itself. Nothing is suggested for you to go and do by hand.
Anything coming back is fenced. A value from a tool that declares untrusted content gets wrapped in a quarantine region, labelled as text written outside the Desk, and rendered as inert text. Data, never instruction.
Every hop is recorded. Seventeen pinned fields per row, tab separated, byte stable, and it round trips: export the ledger and reimport it and you get the same bytes back. Who asked whom for what, the policy decision, the duration, and whether the approval came from the scripted run or from a model.
There are three ways to drive it, and a judge needs nothing for any of them.
- Press
Run the relief scenario. No key, no sign in, nothing to configure. A declared step list drives all seven verbs, crosses two origins, and stops at the approval. - Open the agent controls and press
Use the hosted endpoint, no key needed. Now a model picks the verbs and the order, and nobody wrote that order down. It opens its own lease, reads capacity, drafts a plan, and hits the same halt in front of the same person. - Point a WebMCP extension or the Chrome DevTools WebMCP panel at the Desk. Seven declarations with schemas, read only hints, and a stated return shape on each one.
There is also a topology view that draws the session as it happened, and an evidence page that lists sixteen security claims with a label on each saying what actually backs it.
How we built it
A pnpm monorepo, TypeScript end to end, deployed as six static sites plus one small endpoint.
Front end. React 19 and Vite for all five product apps. Tailwind CSS 4 on the partner sites, because a partner site should look like a small charity built it. The Desk runs its own design system instead: a fixed eight by four grid that closes at 1280 by 800 with no page scroll, a four tier elevation ladder, and a set of pinned design tokens with a build check that fails if a value drifts off the four pixel base unit. The topology view is three.js through React Three Fiber.
WebMCP. A platform wrapper sits over document.modelContext and is the only
module in the entire repository allowed to name it. A syntax tree gate enforces that,
rather than a code review convention. Under the wrapper is the vendored polyfill,
pinned byte for byte, so the Desk behaves the same in a browser with native WebMCP
and in one without.
The broker. @ferrule/broker holds the frame host, the lease manager, the policy
engine, the approval gate, the quarantine wrapper, and the ledger. It contains no
React at all, which is deliberate: the lease state machine, the policy table, the
digest, and the serialiser all run in a node test project against fakes, with no DOM
anywhere near them.
Origins. Every origin value lives once, in an origin registry. A build gate fails
on a raw origin string literal anywhere else in apps/ or packages/. All five
product origins serve Origin-Agent-Cluster: ?1 on every path, and a gate fails the
build if a site configuration drops the rule.
The keyless agent. gateway/ is an OpenAI compatible endpoint on its own separate
Netlify site, deployed outside the workspace and outside the root TypeScript project.
It holds a model key so a reader does not have to, and it bounds what it will spend:
one model, a token ceiling written over rather than trusted, a message count, a body
size checked before parsing, and a daily request allowance kept in Netlify Blobs.
Refusals answer 200 with an assistant message, because an agent reading a 4xx learns
less than an agent reading a sentence.
Hosting. Netlify, seven sites. Five product origins, one deliberately unkeyed probe origin that carries no product code and exists only to be measured, and the gateway
Challenges we ran into
The one that nearly sank the whole thing. A judge is most likely to arrive with a
browser extension. An extension's content script runs in an isolated world: it shares the
page's DOM and it does not share the page's JavaScript globals, because the user agent hands
each world its own wrapper for the same node. document.modelContext is a property on
document whenever it arrives from a polyfill, and a polyfill is what runs in every browser
that does not implement WebMCP natively, which today is most of them.
So I measured it, on the deployed Desk, in plain Chrome with no flags, reading the same expression in both worlds through the DevTools Protocol. In the page's own world the surface was present with all three of its members. In an isolated world it was absent entirely. An extension pointed at that page reads nothing at all. The tools are there. The extension cannot reach them.
The answer is a cross world bridge. The Desk publishes its seven declarations as JSON inside
a script element, because the DOM is shared across worlds, and it accepts invocations by
window.postMessage, because that channel crosses the boundary too. Every invocation goes
through the same wrapped verb body the platform registered, so a hop arriving over the bridge
lands on the ledger exactly like any other.
Two bugs surfaced while building it, and the second one is my favourite thing I learned all
month. Posting a message to a window means you also hear your own message. A caller matching
replies by correlation id alone resolves on the echo of its own request and reports that the
bridge answered with a message kind the bridge never sends. Nothing was broken. The caller
simply could not tell the two directions apart. So a direction field is on the wire now, a
reply carries it, a request may not, and the bridge refuses anything that arrives claiming to
be a reply.
The consent call that was not there. I had planned the human approval around
requestUserInteraction. Boot found a model context surface carrying registerTool,
getTools, and executeTool, and that member was not among them, in either runtime I care
about. So the approval gate is built out of the Desk's own interface and a digest over the
plan's substance instead, which turned out to be the stronger design: the digest is checkable,
it survives a relabel, and it voids on any change to what the plan actually does.
Origin keying, measured rather than assumed. WebMCP is specified to require an origin keyed
agent cluster. I wanted to know whether that was enforced, and the honest way to find out needed
a sixth origin, because keying is sticky per origin and asking the question from a product origin
would poison that origin. So there is a throwaway origin serving no Origin-Agent-Cluster header
and carrying one measurement document. In Chrome with the flag enabled, that unkeyed document
registered a tool, invoked it without refusal, and had that tool surfaced to the Desk, while the
keyed arm passed in the same run. The comparison holds because it is measured against a working
baseline. All five product origins serve the header regardless, and the platform wrapper reads
window.originAgentCluster back at boot before it registers anything, so if enforcement lands
during judging, every origin already complies.
Two argument shapes. executeTool takes arguments in two shapes and they are not
interchangeable. One runtime actively refused the object shape with UnknownError: Failed to parse
input arguments and accepted the JSON string shape. Ferrule depends on neither. It sends the shape
the probe recorded, reissues once in the other shape only on a refusal that names an argument shape
problem, guards that reissue with readOnlyHint so a retry cannot duplicate a side effect, caches
the working shape for the session, and writes the accepted shape onto the ledger hop.
A frame that vanished. A view that replaced the working screen unmounted the frame stage, and unmounting the frame stage detaches the container node, which removes every mounted partner frame and ends every lease depending on one. Switching to the health page killed live leases. The fix is that the working screen is now always rendered and the other views paint on an opaque layer above it, so frames stay mounted and stay driven while you read something else.
A sticky header punching through an opaque layer. The ledger's sticky header cells carry a
z-index of one, correctly, and with the view layer at z-index: auto that one beat it. The symptom
was the ledger's entire seventeen column header row floating on top of the overview headline. Two
declarations fixed it, and the durable half is isolation: isolate on the grid, which confines
every z-index inside the working screen to that screen's own stacking context so no future sticky
element can reach past the layer either.
Ten rows that would not fit. The ledger showed nine records when it needed ten, and three attempts to find the missing pixel and a half looked everywhere except the one declaration that was winning. A stylesheet said the table was eleven pixels over fifteen. An inline style object said twelve over sixteen, and an inline style beats every selector, so the stylesheet had never been in effect and no amount of reading it could tell. That is now written down at the top of the file as a rule: if a component sets a property inline, the value lives in the component.
Accomplishments that we're proud of
The halt. commit_plan succeeds and goes nowhere, and it tells the caller so in words that a
model actually acts on. Getting an agent to stop, report, and wait, rather than loop or fail, is the
single hardest behaviour in the whole project and it works.
Sixty six numbered correctness properties, every one tagged and covered by a property based test, with a build gate that reads the design document as the source of truth and fails if a property loses its test. The gate cannot be quietly satisfied.
A claim register that labels its own claims. Sixteen security claims, each carrying a label saying
whether it is enforced or declared, each cross referenced against its evidence by a gate, and rendered
on a page in the product. Three of the sixteen are labelled declared, and the gate will not let any
document in the repository upgrade one by accident. There are sixteen banned phrasings and every
interface string and every document is scanned against them on every build.
A ledger that round trips byte for byte. Seventeen pinned fields, a tab separated encoder and a parser held to each other by property tests over generated records.
Three runtime findings committed as evidence rather than asserted, validated against a schema by a gate, and cited on a page a judge can open.
A cross world bridge that makes the tools reachable from the client an extension author would actually use. That finding generalises past this project to any polyfilled WebMCP site.
Everything a judge needs works with nothing configured. No key, no sign in, no browser flag. The scenario runner drives all seven verbs, and the hosted endpoint lets a model drive the same seven with no credentials of your own.
What we learned
Isolated worlds are the sharp edge of WebMCP right now. A tool surface that arrives by polyfill is
invisible to the exact client most people will bring. Anyone building a WebMCP site should measure this
in both worlds before they assume an extension can reach them, and publishing declarations into the DOM
with a postMessage channel beside them is a small amount of code for a large amount of reach.
Measure the runtime, do not cite the spec. Three of my design assumptions were wrong and each one took a measurement to find. A consent call that is not there. Keying that is not enforced in one runtime. An argument shape actively refused rather than merely undocumented. Committing those measurements as evidence changed how I write, because a claim with a measurement behind it can be labelled honestly and a claim without one cannot.
A refusal should be a value. Every refusal in Ferrule is a returned sentence rather than a thrown exception, and that single decision is why an agent can read what happened and choose what to do next. An exception is a dead end for a caller that is a language model.
The seam is the product. Any one site can publish excellent tools for itself. Nobody can publish the tools for what happens when four organisations have to cooperate on one request, under a person's authority, with a record afterwards. That gap is where the interesting work is, and it is structurally where a single owner cannot go.
Build the check before the feature. The gates caught things a review would not have: a raw origin string, a phrasing that overclaimed, a property that lost its test, a demo script whose beats no longer added up. Twenty five checks in one command changed how fast I could move, because I stopped having to remember any of it.
What's next for Ferrule
A real partner SDK. @ferrule/partner-kit already exists and holds the seed, store, idempotency
ledger, and health reporting a partner needs. The next step is publishing it so a charity can add four
tools to their existing site and appear on someone else's desk without changing anything they already run.
Multi desk federation. Two dispatch desks with overlapping partners, leases that a partner can see and revoke from their own page, and a ledger that reconciles across desks.
Signed approvals. The plan digest is the right shape for this already. Signing it means an approval travels with proof of who gave it, which is what a real relief network with an auditor needs.
Native WebMCP as it lands. The platform wrapper is a single seam and the vendored polyfill sits under it, so the day a browser ships the surface natively, Ferrule already reads it, reports it on the health page, and the bridge stays where it is for the clients that still need it.
A revocation that reaches further. Leases already come down on close, expiry, and revocation. The next version pushes a revocation to a partner's own page so a volunteer can pull a lease from their side of the seam, not just from the desk's.
And the one that matters most to me: getting it in front of someone who actually coordinates relief. My uncle's notebook is a ledger now, and it exports. I would like to know whether that is useful to the next person holding the biro.
Built With
- chrome-devtools-protocol
- codex
- fast-check
- gpt-4o-mini
- iframe
- jsdom
- model-context-protocol
- netlify
- netlify-blobs
- openai
- opencode
- playwright
- pnpm
- postmessage
- property-based-testing
- react
- react-three-fiber
- sha-256
- tailwindcss
- three.js
- typescript
- vite
- vitest
- web-components
- webmcp
Log in or sign up for Devpost to join the conversation.