Inspiration

A billing ledger is the one place where "let the agent do whatever it wants" is not an acceptable default. An agent that drafts an invoice is useful. An agent that sends one, or marks one paid, on its own authority is a financial incident waiting to happen.

Today freelancers get one of two bad options: keep the agent out of billing entirely, or give it the same access a human has and simply trust it. Countersign is the third option - your agent drafts, you countersign.

Why this use case is a strong fit for WebMCP

The baseline value is obvious: instead of an agent scraping a DOM and hoping the button it clicked was the right one, the site declares an explicit, typed contract. For money, "hoping" is not a strategy.

But the deeper fit is a gap in the platform. The published WebMCP IDL has no confirmation or consent primitive. requestUserInteraction() appears in Chrome's own tool-security guidance as a draft idea, but it does not exist in the published spec - I checked the IDL, and cross-party consent management is still an open discussion.

So an agent-native app that touches money has to answer that question itself, today, using only the primitives that do exist: AbortSignal, annotations, and ordinary application state. Countersign is a concrete answer rather than a wait for the platform to catch up.

What people and agents can do together that was difficult before

Hand an agent real financial authority while keeping a hard, site-owned veto on the two steps that actually move money.

The agent can read the ledger, draft invoices, edit drafts, add clients, schedule reminders, chase late payers, and log client replies - on its own. Sending an invoice and marking one paid always stop and ask. That trade-off barely exists today; it's normally all-or-nothing.

Two mechanisms make it inspectable rather than a trust exercise:

1. Tiered confirmation. send_invoice raises a light, single-screen approval - sending is routine. mark_invoice_paid always raises a heavier one, and if the amount the agent reports doesn't match the invoice, the approve button stops saying "Approve" and instead says "Accept $2,400 instead of $3,000." You cannot wave the mismatch through without reading it. Decline, and the tool returns a plain DECLINED - ... string instead of throwing, so the agent reports back honestly rather than silently retrying.

2. Untrusted-content quarantine. Client replies are third-party text arriving as ordinary business data - a perfect prompt-injection vector. log_client_reply and read_client_replies both carry untrustedContentHint, and every reply runs through a pattern scanner (ignore previous, disregard, system:, mark ... as paid, you are now, override, and similar). A flagged reply is stored, never dropped, but rendered with a visible amber "Quarantined - not acted on" badge listing exactly which patterns matched - and the tool's own return string tells the agent plainly: this is data, not instructions. Three layers of defence: the protocol annotation, the in-band tool response, and the human-visible badge.

How it creates a better user experience

The confirmation isn't a modal that nags you - it's the only place the agent can't proceed, and it shows you the exact amounts before anything happens. Every gated call, every decline, and every quarantined reply lands in a live activity feed and tool-call console, so the agent's work is auditable after the fact instead of taken on faith.

It also works with or without an agent. The invoice panel has human Your actions buttons that dispatch the same reducer actions the WebMCP tools dispatch, carrying actor: 'human' instead of 'agent' - identical guards, identical audit log, attributed separately. Open it in a plain browser and it's still a working billing desk.

How I implemented WebMCP

The Imperative API directly - no third-party wrapper library. 11 tools, registered with document.modelContext.registerTool.

  • StrictMode-safe registration. registerTool rejects with InvalidStateError on a duplicate tool name rather than replacing (confirmed in the spec's own algorithm steps), so React's double-invoke would break naive registration. A fresh AbortController is created inside each effect pass and only ever aborts its own registrations.
  • No stale closures. Tools are built once and read live state through a ref, so a tool registered at mount never sees a frozen ledger.
  • Abort-aware confirmation broker. The gated tools park on an in-page requestConfirmation() that settles on human approval, human decline, or the tool's own AbortSignal firing - an aborted agent call can never leave a dialog hanging and deadlock later calls.
  • Annotations used deliberately: readOnlyHint: true on reads, explicit false on writes, untrustedContentHint on both tools that carry client-written text.
  • Chrome's character budgets respected - every tool name <= 17 chars (limit 30), every description <= 196 (limit 500), every parameter <= 61 (limit 150), and outputs clamped to 1.5K.

No backend, no auth, no database: one reducer is the only place ledger data mutates, persisted to localStorage. Both the human path and every agent tool dispatch through it.

Challenges I ran into

  • useSyncExternalStore compares snapshots by reference, so returning a freshly-mapped array from getSnapshot sent React into "Maximum update depth exceeded." The snapshot is now recomputed only when the queue actually changes.
  • The tool-call log went silently empty in development: a tool's execute() closes over the logger at registration time while the console subscribes at render time, so Vite HMR re-instantiating the module left registered tools writing into an orphaned copy. Anchoring the store to globalThis makes both halves agree however many times the module is evaluated.
  • A hand-written -webkit-backdrop-filter made Lightning CSS ship only the prefixed property - which current Chrome doesn't support - so the frosted UI silently did nothing. Removing the manual prefix let the compiler emit both.

What I learned - and the limit worth naming

An in-page checkpoint can be clicked by an agent that also drives the browser. Approval now requires a trusted event, so script-dispatched clicks (button.click() from a content script or console) are refused. But an agent acting at the input layer via CDP or WebDriver produces clicks indistinguishable from a human's by design, and no page-level code can close that gap.

That's not a footnote - it's the argument. In-page consent gets you real, inspectable protection against the realistic threats: a confused agent, an over-eager one, and injected instructions arriving as client data. Going further requires a browser-mediated prompt that a page can request and no page-level actor can synthesise - exactly the requestUserInteraction() primitive WebMCP still lacks.

Countersign shows how far in-page consent can go, and precisely where the platform has to take over.

Built With

Share this project:

Updates

Submission history