Skip to content

Repository files navigation

Ferrule

A dispatch desk where a human and an agent work one shared queue across five independent origins, and every hop either of them takes is leased, policed, approved, and recorded.

Live now, no sign in, no API key, no browser flag:

Open the Desk https://ferrule-desk-mcp.netlify.app
Partner sites pantry · shelter · rides · cold chain
Measurement origin probe-unkeyed
Watch the demo https://youtu.be/Rb5cwQJOyZQ
Shot list docs/demo-video-script.md, eight beats, 180 seconds

A ferrule is the metal collar that binds independent parts into one working tool. That is the design in one word. Nothing is handed over, and the collar is the only new part.

Try it in ninety seconds

Three paths, in the order a reader should take them. All three are on the live Desk right now.

1. Press Run the relief scenario. No setup at all. Watch a lease open on the pantry, a partner site mount itself into the Desk as an iframe, real capacity come back across two origins, the run stop dead at a human approval, and a row land on the ledger for every hop.

2. Open the agent controls and press Use the hosted endpoint, no key needed. Type something in plain words, for example find shelter for four people tonight and draft a plan. A model now picks the verbs, in an order nobody wrote down. It opens its own lease, reads capacity, drafts, and hits the same halt. Every verb it chose is listed in the turn, and every hop is on the same ledger.

3. 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 for each. Invoke one and watch the page change. See The isolated world problem for the measurement that made this work at all.

Product thesis

The agent native web will be many small independent sites rather than one platform. Every organisation in a mutual aid network already has its own site, its own data, and its own reasons not to hand either to anyone else. WebMCP lets each of them expose tools without giving up any of that.

So the missing piece is not another tool surface. It is a brokering and consent layer in the seam between origins, and that is exactly where a single owner tool cannot go. A shopping site can publish excellent tools for its own checkout. Nobody can publish the tools for what happens when four organisations have to cooperate on one request, under a human's authority, with a record afterwards.

Ferrule is that seam. Five deployed sites on five distinct origins from one monorepo: a Desk plus a food pantry, a shelter, a ride collective, and a cold chain supply closet.

  • The Desk registers seven broker verbs: intake_request, find_capacity, draft_plan, commit_plan, open_lease, close_lease, read_ledger.
  • Partner tools are reachable only inside a time boxed, purpose scoped capability lease that mounts the partner frame on demand and takes it down on close, expiry, or revocation.
  • Every write passes a human approval keyed to a digest of the plan's substance. Relabel the plan and the digest holds. Change the origin, the tool, the arguments, or the order, and the approval is void.
  • Every value from a tool declaring untrusted content is wrapped in a quarantine region and rendered as inert text.
  • Every hop is appended to a tab separated, byte stable ledger of 17 pinned fields, and it is the largest region of the interface, because the record of who asked whom for what is the product.

What gets meaningfully better when an agent is in the room

This challenge asks for an app that is better when people and their agents use it together. Ferrule is built on the observation that a shared queue is one of the few things where that is literally true, and where the agent's presence creates a problem worth solving rather than just saving keystrokes.

Without an agent With an agent, on Ferrule
A dispatcher phones four organisations in sequence and writes it down An agent reads live capacity across four origins in one turn, and the writing down is automatic and byte stable
Cross organisation trust is a phone call and a habit Trust is a lease with a scope and a deadline, a policy verdict, and a digest a person signed
An agent acting on your behalf is a black box Seven declarations with schemas, a turn that lists every verb it chose, and a ledger row per hop, all on the screen while it happens
"The agent did something" Ledger record 36, Desk to Pantry, frame_unmounted, status ok, policy not_applicable

Two design decisions carry most of that.

The human is in the path, not in a settings page. commit_plan halts. It is the one call in the loop that succeeds and produces no continuation, and it says so in a form an agent can read:

status: awaiting_human_approval
approval_id: 4
commit_plan raised plan-3 for human approval as 4. Nothing ran: no partner state
changed, no lease was created, and no step was invoked.
plan_digest 9f2c...
next: none of this desk's verbs continues this plan. A person approves or rejects
it, and the approved steps run then. Report that the plan is raised and waiting,
and stop; re-calling commit_plan raises a second approval for the same plan.

An agent that reads that reports the plan as raised and stops. It does not report failure, and it does not loop.

The agent triggers real work and you watch it land. A write arriving through the broker updates the partner's own interface inside its own mounted frame, on its own origin. There is an integration test named for exactly that: shelter shows the change in its own mounted interface once the dispatcher approves. Nothing here suggests an action for a human to go and perform.

The three findings

Measurements taken by the Probe_Harness from the deployed Desk origin and committed as evidence in evidence/probe/. Two runtimes: a flag enabled Chrome build (chrome-flag-enabled.json) and the ChatGPT desktop in app browser (chatgpt-desktop.json).

1. requestUserInteraction is absent in both target runtimes. Boot found a model context surface carrying registerTool, getTools, and executeTool, and requestUserInteraction was not among its members. Both reports record request_user_interaction_present as fail. Nothing in Ferrule depends on that member, so the human approval gate is built out of the Desk's own interface and the plan digest rather than out of a platform consent call. The Health_Page reports the one bit fact for the runtime you are looking at.

2. The unkeyed document was not refused in Chrome with the flag enabled. WebMCP is specified to require an origin keyed agent cluster. That runtime does not enforce it, and Ferrule measured it rather than only citing Chromium issues 521181015 and 522193904. A document on the throwaway sixth origin, which serves no Origin-Agent-Cluster header, registered a tool, invoked it without refusal, and had that tool surfaced to the Desk. The comparison holds because the keyed arm passed in the same run, recording cross_origin_registration as pass, so the unkeyed result is measured against a working baseline rather than read out of an absence. Keying is per origin and sticky, which is why the question needs a sixth origin to ask from: asking it from a product origin would poison that origin.

The pass that chatgpt-desktop.json records for the same check is confounded and is not read as enforcement. In that runtime the keyed arm surfaced zero tools too, which is why the same report records cross_origin_registration as fail. One observation, two names. The confound is recorded in the design and in the register, and no claim labelled enforced reads that check.

Every product origin serves Origin-Agent-Cluster: ?1 on every path regardless, a gate fails the build if a product site configuration drops the rule, the Platform_Wrapper reads window.originAgentCluster back at boot before registering anything, and the Health_Page shows the result per origin. If enforcement lands during judging, all five origins already comply instead of failing at once.

3. The two executeTool argument shapes are not interchangeable. Chrome with the flag enabled refused the object shape, raising UnknownError: Failed to parse input arguments, and accepted the JSON string shape. So the object shape is not merely undemonstrated in that runtime, it is actively refused. Ferrule depends on neither shape: it sends the shape the probe recorded, defaults to the JSON string shape that Chrome's published documentation demonstrates, 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 records the accepted shape on the ledger hop.

The isolated world problem, and what we did about it

A fourth measurement, and the one with the widest reach beyond this project.

A browser 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: a 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.

Measured on the deployed Desk in a Chromium with no flags, reading the same expression in both worlds through the Chrome DevTools Protocol:

document.modelContext registerTool getTools executeTool
Page's own world present function function function
Isolated world absent absent absent absent

An extension pointed at a page whose tools arrive by polyfill reads nothing at all. The tools are there. The extension cannot reach them.

Ferrule publishes the same seven declarations twice, over the two channels that do cross a world boundary:

  • In the DOM, as the JSON text of a script element with id ferrule-agent-tools. The DOM is shared, so any extension, panel, or reader parses it with no cooperation from the page. Each entry carries the name, title, description, purpose, inputSchema, readOnlyHint, and untrustedContentHint.
  • Over window.postMessage, which crosses between worlds. Post {envelope: "ferrule.agent.bridge", version: 1, kind: "invoke", id: <yours>, verb: <a name from the manifest>, input: {...}} and a reply arrives on the same window carrying direction: "reply" and either a text or a reason. Match on that direction field: posting to a window means you also hear your own request.

The manifest states its own invocation contract, so an extension needs no other document. The bridge adds no capability: a call through it runs the same wrapped verb body, appends the same ledger row, needs the same lease, and stops at the same human approval.

tests/integration/agent-bridge.spec.ts measures both worlds through the DevTools Protocol, and its first example asserts the gap itself, so the result stays checked rather than becoming folklore.

The trust boundary

A browser agent observation is not filtered by the exposed origin list. The specification has such an observation walk inclusive descendant navigables and collect the tool map from every document permitted to use the tools feature, with no filter on exposedTo. So the tools of a partner frame mounted at that moment are within what such a walk collects. exposedTo is a declaration, not a filter applied to page observation.

Ferrule enforces through frame lifecycle instead. The Desk document carries no partner frame at load. A frame appears only when a lease enters pending, and it is removed, its cached discovery result discarded, and its in flight invocations aborted the moment that lease reaches a terminal state. What any walk of the page can collect is therefore bounded by what a lease is permitting at that instant, and that bound is the enforced part: claims C1, C2, and C16 are property tested by Properties 16 and 17. The guarantee rests on the Frame_Host removing the frame element, which destroys the child document and every tool registered in it, rather than on signal based tool unregistration, because Chromium issue 515272360 reports that signal based cleanup can fail after a delay and garbage collection and leave a tool un-removable.

The register labels C3 declared for the matching reason, and names the mitigation on it: Ferrule narrows the window in which a mounted frame's tools exist, and the agent_observation_sees_frame_tools check is incomplete in both committed reports because no agent observation seam was supplied. Claims that read a fail or an incomplete check do not carry the enforced label, and a gate fails the build if one tries to.

Architecture

Six deployed origins, five product plus one deliberately unkeyed probe origin that carries no product code and exists only to be measured:

Origin Role Origin-Agent-Cluster: ?1
ferrule-desk-mcp.netlify.app Desk: 7 broker verbs, every partner frame, Scenario_Runner, In_Page_Agent, cross world bridge, Probe_Harness page, Health_Page served
ferrule-pantry-mcp.netlify.app Pantry partner, 5 tools served
ferrule-shelter-mcp.netlify.app Shelter partner, 5 tools served
ferrule-rides-mcp.netlify.app Rides partner, 4 tools served
ferrule-coldchain-mcp.netlify.app Cold_Chain partner, 5 tools served
ferrule-probe-unkeyed-mcp.netlify.app one measurement document, no product code deliberately absent

Every origin value lives once, in the Origin_Registry at packages/config/src/origins.ts. A gate fails the build on a raw origin string literal anywhere else.

The module graph is acyclic and one directional:

@ferrule/config      Origin_Registry, Scope_Map, Compensation_Map, tokens, constants
  -> @ferrule/webmcp       Platform_Wrapper + Vendored_Polyfill
  -> @ferrule/broker       Frame_Host, Lease_Manager, Policy_Engine, Approval_Gate,
                           Quarantine_Wrapper, Ledger
  -> @ferrule/partner-kit  seed, store, idempotency, health
  -> @ferrule/ui           tokens, badges, inert renderers

apps/desk       <- broker, ui, webmcp, partner-kit, config
apps/pantry, shelter, rides, coldchain  <- partner-kit, ui, webmcp, config
apps/probe-unkeyed                      <- webmcp, config only

Nothing imports the Desk. @ferrule/broker is deliberately free of React, so the lease machine, the policy table, the digest, the quarantine pipeline, and the serialiser all run in the node Vitest project against fakes. Only @ferrule/webmcp may touch document.modelContext, and only the Probe_Harness module inside apps/desk may read the unkeyed probe origin entry. Both are gated by parsing the syntax tree rather than by convention.

The five product origins run as static documents with no backend service, no database, and no authentication provider, and a gate fails the build if any of their site configurations declares a function, an edge function, or a proxy redirect. The one server side component in the repository is gateway/, an OpenAI compatible endpoint deployed as its own separate Netlify site so a reader can drive the agent without holding a key. It is outside apps/, outside the pnpm workspace, and outside the root TypeScript project. Switch it off and the Desk still runs its scenario, brokers across origins, approves, and records.

One command runs everything:

pnpm install
pnpm gate

pnpm gate runs 25 checks, 20 static gates in process and 5 as child processes (the environment pinning check, two type checks, and the two Vitest projects). It does not stop at the first failure: every check is independent, so the run reports every failing check by name along with the requirement criteria left unsatisfied. 66 of 66 numbered Correctness Properties are implemented, and the property coverage gate ratchets that floor, pinning each one to the file that implements it, so a property that loses its tag fails the build rather than quietly disappearing.

Suite Count
pnpm gate checks 25, all passing
Correctness Properties, property tested 66 of 66
Rendering examples (jsdom) 174
Playwright integration tests against deployed origins 25
Tool declarations validated (7 broker verbs, 19 partner tools) 26
Security claims in the register 16

Claim labels

Every security claim this project makes is listed in claims/register.json, sixteen of them, each carrying an identifier, the claim text, one label, and an evidence reference. There are exactly two labels and the difference between them is the point:

  • enforced means browser or Ferrule code makes the claim true, and the register names the evidence: a passing property test, or a committed Probe_Check recorded as pass. Thirteen claims carry this label.
  • declared means the project has a position and a mitigation, and the evidence does not carry the claim on its own. Every declared claim states both what Ferrule does about it and the limit of it. Three claims carry this label: C3, the observability of a mounted frame's tools; C4, the exposed origin list as an access control; and C5, origin keyed agent clusters.

The labels are checked rather than asserted. The claims gate fails the build and names the claim when an enforced claim's evidence is missing, failing, or not committed: a referenced property test that does not exist or does not pass, or a referenced Probe_Check with no committed outcome of pass. The overclaim gate then reads the banned phrasing list out of the same file, as data, and scans every interface string, every markup and stylesheet file, and every document in the repository, including this one and the demo video script, failing the build with the file, the line, and the matched phrasing. The register is the single source for both, so loosening a phrasing to let a document pass is a visible edit to the file the labels live in.

Running the Scenario Runner

The Scenario_Runner is the path for a judge with no API key, no sign in, and no browser flag. It is available on the Desk with no configuration, and it stays available under every Support_Mode and every Federation_Mode, including unsupported and simulated, so a runtime with no WebMCP surface at all still shows the full loop.

It drives only the seven broker verbs from a seeded, declared step list and uses no privileged code path an external agent could not reach. It bypasses no lease check, no policy decision, no approval, and no quarantine wrapping: at commit_plan it stops, highlights the approval control, and waits for a human decision, and the ledger records that the approval came from the runner. One scenario opens a lease, reads capacity across at least two partner origins, drafts a plan, obtains approval, executes a write, closes the lease, and appends every hop to the ledger. The declared steps complete inside a 10 second budget with the human decision excluded, and an integration test measures that on every run.

Locally:

pnpm install
pnpm --filter @ferrule/desk dev     # http://localhost:5173

Deployed, open the Desk origin and start the runner from the Desk screen. Reset any partner site before a second run: each partner page carries a Reset this site's data control at the foot of its panels. The Desk cannot press it for you, because partner state lives in each partner's own origin and does not cross between them.

Driving the same verbs with a model instead

The Scenario_Runner reads a step list declared in the repository, so nothing in it is a model choosing a verb. The In_Page_Agent is that, and it drives the same seven closures through registration.bodies, with no path of its own.

It takes any endpoint answering the widely implemented chat completions shape. Two ways to reach one:

  • The hosted endpoint. One control selects it and needs no key. It is a separate site on its own origin, it holds a model provider key so a reader does not have to, and it bounds what it will spend: one model, a token ceiling per request that it overwrites rather than validates, a message count, a body size checked before parsing, and a daily request allowance for the whole site. Source in gateway/.
  • Your own endpoint and key. Type both into the agent controls. A key supplied there is held in memory for the page, travels in a request header to that endpoint and nowhere else, and is in no ledger row and no export.

Either way the agent meets every check any other caller meets: it needs a lease it cannot grant itself, a policy verdict it does not issue, and at commit_plan a person.

Reaching the verbs from outside the page

See The isolated world problem. The short version: read #ferrule-agent-tools out of the DOM for discovery, and post to the window for invocation.

There is also an AXIS scenario in .axis/, whose rubric grades an agent on recognising the halt rather than on completing the task. This repository's own gate does not run it, because it needs an agent CLI on the machine.

Reproducing the probe

The Probe_Harness is a page on the Desk origin, not a script, because it has to measure the runtime a judge is actually using. It runs six named Probe_Checks in a fixed order: cross_origin_registration, agent_observation_sees_frame_tools, request_user_interaction_present, execute_tool_argument_shape, unkeyed_document_rejected, and tool_unregistration_effective. Each completed check records pass, fail, or unsupported, and a check with no seam to measure records incomplete rather than a verdict.

Open /probe.html on the Desk origin, run it, and export the report. A committed report is a JSON document validated against the ferrule-probe-v1 schema by a gate, so a hand edited report fails the build. The two committed reports are in evidence/probe/, and the Health_Page renders both alongside what the runtime you are using reports right now.

Working on this repository

pnpm install
pnpm gate                                   # all 25 checks
pnpm exec vitest run tests/rendering        # 174 rendering examples
pnpm run test:integration                   # Playwright, against the deployed origins
pnpm -r --parallel "--filter=./apps/*" run dev   # six dev servers, ports 5173 to 5178

Node 20.19 or newer and pnpm 9. The environment pinning gate fails the build if the toolchain drifts from what is declared.

The repository is a pnpm workspace: apps/* are the six deployed documents, packages/* are the five libraries, tools/gates/* are the static checks, tests/property and tests/rendering are the two Vitest projects, and tests/integration is Playwright. gateway/ is deliberately none of those and is excluded from the workspace and from the root TypeScript project.

Conventions that a gate enforces rather than a reviewer:

  • no product origin as a string literal outside the Origin_Registry
  • no reach into document.modelContext outside @ferrule/webmcp
  • no document.domain assignment, and no contentWindow, contentDocument, opener, parent, or top reach in outside the vendored polyfill
  • the vendored polyfill pinned by length and SHA-256, with its provenance recorded in NOTICE
  • 17 ledger fields in a pinned order, and a serialiser and parser that round trip byte for byte
  • every tool declaration inside the declared budgets, with a name, a title, a description that states what comes back, a purpose, a schema, and its hints

LICENSE is Apache 2.0. NOTICE records third party provenance, including the vendored polyfill's repository, commit, length, and digest. CUTS.md records the declared cut order, which tools/gates/never-cut.ts reads as its checked set: seven items that survive every declared cut, each verified to exist, export something, and sit on a path a bundler follows from a named build input. Nothing was cut.

specs/design.md is the design document. It is not decoration: tools/gates/property-coverage.ts reads it as the source of truth for the 66 numbered Correctness Properties and fails the build when a property tag and the document disagree, and three digest property tests transcribe its byte layout dumps so the canonical encoder is anchored against the document rather than against itself.

About

A WebMCP dispatch desk where a human and an agent work one shared queue across five independent origins. Every hop is leased, policed, human approved, and recorded on a byte stable ledger. Built for the OpenAI WebMCP Challenge.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages