Measuring two browser agents against the same Chrome turned up the same defect in both: a click or a type that returned success while the page did nothing. Twenty or more in the official extension, two in Autopilot. None caught by a test, because a test that asserts on the tool’s own return value cannot see it. The only honest proof was a DOM listener on the page setting a flag, and watching the flag stay false while the tool said ok.
Fixing two instances would have been a spot fix. The problem is structural: a tool call is a claim, and a claim without evidence is worth nothing to the agent reading it. So every input call now has to evidence what it did.
the contract
Every input tool returns the same shape:
{
"ok": true,
"effects": "applied",
"evidence": { "windowMs": 250, "watched": true, "mutations": 3, "focusChanged": true, "valueChanged": false },
"warnings": [],
"id": "c7f2..."
}
effects is one of three values. applied means the watch saw a change it can name. none means the watch saw nothing within the window. unknown means the watch could not run, or ran and could not see whether a submit landed, with a hint to re-read. A click on an inert element returns effects: none with “no observable change within 250ms”. A type with nothing able to hold text focused returns an error naming what did have focus. A click whose handler swallows the event returns unknown with a re-read hint.
The error codes live in one catalogue in the host, mirrored into the extension, and a parity test fails the build if the two copies drift. Each code carries a cause, a hint, and whether a retry is safe. The id correlates the call with its line in the on-disk action journal.
arming the watch
Before dispatching an input, the content script arms observers on the page: a mutation observer on the document, the active element, the URL, and for a submit-shaped click, the navigation and network signals. Dispatch happens through the DevTools Protocol so the event carries isTrusted: true. Then the watch waits its window and reports.
What counts as an effect turned out to be the hard part. Focus falling back to the body after a click on an inert element is a focus change, and the first version counted it as evidence the click did something. It did not. The watch now treats focus landing on the body as no change.
the submit window
A submit-shaped click gets a longer window, three seconds, and five named signals: navigation started, a fetch or XHR completing with a 2xx, a form’s submit event, a URL change, and a DOM mutation. The first version used 250 ms for everything, and on GitHub the navigation, or the 2xx that proved the write landed, arrived after the window closed. The call reported none on a click that had in fact created the issue.
Which clicks are submit-shaped is decided by the control’s words: create, comment, close, delete, remove, confirm, apply, save, update, submit, ok, done, yes. A separate list governs which actions are irreversible and need a confirmation token. Widening the submit window did not widen what gets gated, because the two lists are independent.
the traps the contract created
- A type inside an iframe reported no effect.
document.activeElementin the parent document reports the frame element rather than the input inside it, so the watch saw the wrong focus target, the host retried, and the text landed twice. The watch now reads focus inside the element’s own root. - A click that opened a modal reported unknown. The mutation was large and outside the clicked element’s subtree. The classifier now attributes a mutation anywhere in the document within the window to the click.
- Shadow DOM hit tests always failed.
document.elementFromPointretargets a shadow descendant to its host, so every click inside a shadow root read as covered by its own host. The hit test now runs inside the element’s own root. - Elements in a scroll container were hit-tested before being scrolled into view, and reported as covered by whatever was on top of them at the time.
- Iframe coordinates went stale. The first fix captured the frame offset when the tree was read, which was wrong after any scroll. Offsets are measured live from the frame chain at dispatch, held per element.
- A stray notification click counted as an answer. The optional in-browser allow-or-deny toast fired its button-clicked event with index zero while nobody was clicking, at 2.6, 4.4, 8.4, and 23.6 seconds in different runs, and once as fourteen activations inside five seconds. Rules added: ignore a click in the first 1.5 seconds, ignore an allow within 500 ms of another click, accept allow once per prompt, and count a click on the toast body as a click without treating it as an answer. The switch ships off, and the options page says why.
refusing instead of guessing
The contract’s other half is refusing calls that cannot be evidenced. A disabled or read-only field refuses a type. A covered element refuses a click and names what covers it. A stale reference, one whose element is gone since the tree was read, refuses and asks for a re-read. Coordinates outside the viewport refuse. Each refusal is a code with a hint, and none of them is a guess.
The alternative, which is what the twenty-plus silent no-ops on the other side were, is to dispatch and hope. An agent that gets a refusal with a reason takes one extra step. An agent that gets a false ok takes every following step on a wrong premise.
the journal
Every call, its arguments, its result, and its evidence go to a JSONL file per browser per day, with a readable Markdown timeline next to it. Sensitive argument values are redacted before writing. When a run goes wrong, the journal is where the answer is, and it is the artifact the comparison campaign was reconstructed from. The official extension has no equivalent. It has telemetry that leaves the machine, which is a different thing.
The contract did not make the tool succeed more often. It made the tool tell the truth about when it did not, which for an agent is the property that matters.