Inspiration

An agent books your flight on one site and your hotel on another. Halfway through, the hotel fails.

Ask what happens next and no part of the web can answer. inputSchema describes a tool's shape. readOnlyHint describes whether it has an effect. Nothing anywhere describes whether that effect can be reversed — which is the one fact an agent needs to plan across sites rather than to act and hope.

So today that gap is filled by a marketplace: an intermediary both businesses have a contract with, which is why one sits between almost every pair of businesses an agent might transact with, taking 15–30% to stand there. The tax is not for the booking. It is for the guarantee.

The failure mode of agentic commerce is not the booking going wrong. It is the agent telling you it went fine.

What it does

Concord is a convention over WebMCP by which any site declares its commitment surface — one of three rungs:

Rung Meaning Example
reservable hold it, release it, nothing happened an airline seat
compensable undoable, but it leaves a mark a hotel charge and refund
irreversible it cannot be taken back a government fee

…and an in-browser coordinator that computes the strongest honest guarantee across several such sites before contacting any of them — then refuses when the honest answer is that atomicity is not available at any price.

Three things to try on the live URL, in ninety seconds:

  1. Ask for a flight, a hotel and the visa fee. The agent reads back what it can promise, which step is the point of no return, and which charge becomes briefly real before being reversed. Only then does a Go ahead button exist.
  2. Ask for a flight, the visa fee and the entry permit — two things nobody can take back. It says it cannot do that as one commitment, and that nothing has been contacted. There is no button on that answer.
  3. Kill the coordinator mid-commitment. Reload. It asks every vendor what actually happened and puts back what can be put back.

Why WebMCP, specifically

This needs exactly the thing WebMCP uniquely provides and nothing else does: two mutually distrusting origins' authenticated, typed capabilities present in one execution context, under the user's authority, with consent enforced on both sides by the browser.

That is not scraping and it is not an API partnership. A participant grants with registerTool's exposedTo. The embedder grants with allow="tools". The coordinator must ask by name with getTools({ fromOrigins }). Three separate grants, none forgeable by any party, and none requiring the participants to have heard of each other. No server anywhere is in the commitment path.

How WebMCP is implemented

The permission model is registerTool itself. This is the idea the whole project rests on:

concord_commit does not exist until a person accepts the exact guarantee they were shown. Not disabled. Not registered.

Four read-only tools at boot. concord_explain_guarantee appears once something is proposed. Explaining is not consent, so the surface does not change when the agent reads the guarantee out. A human clicks Accept on the page — registerTool puts concord_commit on the surface. The commitment begins — an AbortController takes it away again, so a second call arriving mid-flight finds nothing to call. toolchange fires on every one of those transitions, and you can watch it in the tool inspector.

There is no tool that grants that permission, and there will not be one. An agent has every door; none of them opens this one.

The rest of the surface actually used:

  • exposedTo — each participant exposes its tools only to the coordinator's origin, so cross-origin consent is explicit on the participant's side.
  • allow="tools" + Permissions-Policy: tools=(self "…") — the embedder's half of the same consent, verified on every deployed origin.
  • Origin-Agent-Cluster: ?1 — required for cross-document tool access; without it getTools() returns empty with no error.
  • getTools({ fromOrigins }) — discovery, opt-in on both sides, asked one origin at a time.
  • executeTool(tool, jsonString) — arguments as a JSON string, per Chrome's imperative API.
  • annotations.readOnlyHint — used honestly: true on the two tools that perform nothing, and on nothing that acts.
  • AbortSignal — a deadline on every call, enforced by the coordinator too, which does not assume a participant honours cancellation.
  • modelContext.requestUserInteraction — feature-detected. Chrome 151 does not implement it, so we measure that and use it wherever it exists: a second door onto the same accept, deliberately not a lock. A test hands it a host that approves every request and requires the surface to stay exactly as it was. If asking for attention could produce consent, the central claim would be false.

And it fills a gap in the standard rather than decorating it. SPEC §19 proposes the missing annotation upstream:

annotations: {
  readOnlyHint: false,
  commitment: 'reservable' | 'compensable' | 'irreversible',
  reverses: 'release_seat',
  ttlSeconds: 900,
}

The receipt

Every commitment produces an RFC 6962 Merkle receipt in which every statement is signed by the counterparty that made it, with per-vendor ECDSA P-256 keys resolved from each vendor's own origin over TLS — never from a map the coordinator writes.

The outcome is derived from the statements, never read off the receipt, because that field is the one thing no vendor signs. Check one on an origin we do not run, or with npx concord-verify receipt.json, and nothing of ours executes.

How we built it, and what broke

Zero dependencies, on purpose. Node's own test runner, a ~100-line property-testing kit, and Chrome.

An audit found the receipt was forgeable. Seven of fourteen forgeries verified clean: a coordinator could delete a charge, relabel the outcome "Committed", and the receipt passed, because verifyReceipt gated its strictness on a string nobody signed. Fixed by deriving the outcome from the entries and by provable silence — a vendor that did nothing signs that it did nothing, so an absence is distinguishable from a deletion. You can fire all fourteen at our own verifier from the live site.

Running on the real API found two defects a polyfill can never surface. The coordinator was not delegating tools to the origins it embeds, so every participant registered and none was discoverable. And Chrome returns inputSchema as a JSON string where the polyfill returns an object. Both worked flawlessly under the shim — which is the argument for never trusting one.

getTools({ fromOrigins }) rejects for the whole call when any one origin is unreachable. One dead site produced "0 of 6 answered" and a failure screen with five participants healthy. Discovery now asks one origin at a time.

What we learned

That a sampled mutation score is not a score. We reported 92%. It came from 60 mutants taken every-Nth from a list sorted by file and by operator — a stratified sample of a sorted population. Running all 116 gave 87%. Closing what that exposed took it to 97% (112/116), and the four survivors are documented as equivalent with the reasoning attached, not hidden.

That a property test which cannot fail passes loudest. Our first reference-model test registered the commit tool in 1% of the states it checked, and its unwind-ordering property was vacuous in 95% of runs because the fixture had one participant of each rung — so reversing and not reversing produced identical output. Mutation testing found it. Every property now asserts how much of the interesting space it reached and fails rather than going green faster.

That the measuring instrument needs measuring too. Our mutation runner's timeout killed the npm wrapper but not the test process beneath it; two orphans took throughput from ten mutants a minute to ten every three — and a loaded machine turns survivors into false kills, silently inflating the score.

Challenges

Refusing well is harder than committing. Anyone can run a saga. The hard part is computing, from declarations alone and before contact, that no honest promise exists — and then having no button on that answer.

Proving a negative in a browser. "The commit tool does not exist yet" is only worth something if you can watch it not exist. So the claim is checked through the same getTools() an agent calls, state by state, against the live deployment.

Not overclaiming about our own proof. We ship a formal model — 855 reachable states, 9 safety invariants, breadth-first so a violation reports the shortest trace. spec/Concord.tla is generated from the same declaration the checker executes, so it cannot drift. But TLC has not been run against it, and the README says so in those words rather than letting a .tla file imply a proof nobody ran.

What it can't do — said before you ask

  • A vendor can lie about being compensable. One does, on purpose, on the live URL. Concord cannot prevent it; it makes it attributable, because the vendor's own signature is on the statement it later declines to reverse.
  • Confirm fan-out is not atomic. Two vendors can be ticketed and a third fail. Documented as an open question with the reason no in-tab protocol fixes it.
  • You still have an intermediary. Yes — but it is yours. Disposable, margin-free, and unable to misreport what happened.

Evidence, all regenerated by one command

assertions, no browser 163
protocol states model-checked 855, against 9 safety invariants
ladder configurations, exhaustive 4,164
mutation score 97% over every one of 116 mutants — not a sample
forged receipts rejected 14, fireable live
receipts a malicious coordinator can build, all judged 180
browser suites against real origins 7
independent HTTPS deployments 8
backends in the commitment path 0

npm run check runs all of it, and every number is committed under evidence/ so one that stops being true stops being printed.

What's next

Making it a convention rather than an entry. A conformance suite that judges any origin against the spec — including yours, in a text box. A sandbox where ten lines registers a participant the coordinator has never heard of. A drop-in <concord-receipt> element that verifies in the customer's own browser. A GitHub Action that fails a build when a receipt stops verifying. And the annotation, upstream, where it belongs.

Six vendors we deployed is a demonstration. Vendors we did not build is a standard.

Built With

  • chrome
  • css3
  • ecdsa
  • es-modules
  • github-actions
  • html5
  • javascript
  • json-schema
  • merkle-tree
  • model-checking
  • mutation-testing
  • node.js
  • npm
  • permissions-policy
  • property-based-testing
  • rfc-6962
  • rfc-8785
  • saga-pattern
  • sha-256
  • tla-plus
  • vercel
  • web-components
  • web-crypto-api
  • webmcp
  • zero-dependencies
Share this project:

Updates

Submission history