Most "give the model a browser" setups hand it one of two things: a screenshot, or a dump of the full accessibility tree. Both are expensive, and both get re-sent in full after every action.

I've been building ecobrowser, a TypeScript library and MCP server on top of Playwright whose whole job is to make perception cheap for a model. This post walks through three pieces of it, using the real code: a compact snapshot format, a cache keyed on the DOM, and a diff for re-perceiving after an action.

1. A snapshot the model can act on

A snapshot is not a picture of the page. It's a list of interactive elements:

interface SnapshotElement { id: string; // durable id, e.g. "e3" role: string; name: string; tag: string; value?: string; state?: string[]; // disabled, checked, expanded, obscured, ... options?: string[]; // a <select>'s choices } interface Snapshot { url: string; title: string; elements: SnapshotElement[] } 

Each element is stamped with a durable data-ai-id, and the model acts by id (click e3), never by CSS selector or pixel coordinate. The state array carries what the model needs to act correctly, and covered elements are flagged obscured. Password values are never captured and show as [redacted].

The snapshot is capped at 250 elements, and it only covers interactive elements. That's a deliberate scope choice, and it matters for the benchmark below.

2. Cache the snapshot until the DOM changes

Re-walking the DOM and re-serializing on every call is wasteful when nothing changed. The engine runs a MutationObserver that counts real DOM changes (ignoring its own data-ai-id stamping), and snapshot() serves the previous result when the DOM version and URL match:

async snapshot(opts: { cache?: boolean } = {}) { const version = await this.domVersion(); if (opts.cache && this.lastCacheable && this.lastSnapshot && version === this.lastDomVersion && this.lastSnapshot.url === this.page.url()) { return this.lastSnapshot; // skip the full DOM walk + serialization } // ...otherwise build a fresh snapshot } 

3. Re-perceive with a diff, not a full page

After a click, the model rarely needs the whole page again. changes() returns only the delta:

interface SnapshotDiff { added: SnapshotElement[]; removed: SnapshotElement[]; changed: { id: string; name: string; from?: string; to?: string }[]; unchanged: number; } 

The interesting part is how elements are matched between the two snapshots. Ids that appear exactly once in both are matched by id, so a relabelled button shows up as changed rather than as one removal plus one addition. Everything else is matched by a semantic signature (tag|role|name) plus an occurrence index, so five identical buttons are matched positionally instead of collapsing into one key. A toggled checkbox counts as changed because the comparison includes value and the state flags, not just the label.

4. find() without an LLM

For "just give me the search box", there's a deterministic scorer, elementMatchScore. Words like box, field, button and link are treated as kind hints and matched against tag and role. The remaining words are scored against the accessible name: exact beats prefix beats substring beats all-words-present. A kind match boosts the score and a kind mismatch demotes it, so "search box" ranks the search input above a "Search" button. It's cheap precisely because no model is involved.

What the numbers say (and don't)

The repo includes a head-to-head benchmark against Playwright MCP (npm run bench) on the same page, measuring bytes returned to the model and tool latency:

ecobrowser Playwright MCP
Full page snapshot ~41 KB (~10K tokens) ~128 KB (~32K tokens)
Re-perceive latency ~5 ms (cache hit) ~150 ms
Re-perceive after an action delta only full page again

The caveats matter, and they're in the README too:

  • This measures perception payload and tool latency, not end-to-end LLM wall-clock. No live model ran.
  • Part of the size gap is scope: ecobrowser captures interactive elements only, while Playwright MCP captures the full accessibility tree.
  • It's faster than the wrapper (Playwright MCP), not than Playwright itself, which is the shared engine under both. The wins are caching, diffing and a leaner format, all of which a competitor could adopt.

Honest limits

It's a young, single-user developer tool, tested on its own paths (34 unit tests, 21 browser regression tests and 7 MCP-level regression tests, all run in CI) but not battle-hardened across thousands of real sites. It's meant to be pointed at your own or authorized sites.

Try it

claude mcp add ecobrowser -- npx -y ecobrowser-mcp 

or npm install ecobrowser and use it as a library. Source: https://github.com/HaidarESBER/ai-browser