gripv0.8.2
v0.8.2Three-way benchmark, 2026-08-10

A browser your agent can afford to look at.

grip is a CDP-native Python SDK. Point it at a page and your model gets the interactive elements and the visible text, indexed and fuzzy-matchable, small enough to keep in context for a whole run. No Playwright, no Puppeteer, no wrapper binary.

~18xfewer prompt tokens across a 6-turn run. 16.9x - 18.4x across repeat runs of the benchmark.
$ pip install grip-browser
Source
await page.snapshot()what the model sees
PAGE: Hacker News
URL: https://news.ycombinator.com/
 
INTERACTIVE:
[inp:0] "Search" (placeholder)
[btn:1] "Go"
[lnk:2] "new"
[lnk:3] "past"
[lnk:4] "comments"
 
CONTENT:
1. Ask HN: what are you working on this week?
2. A tour of the Chrome DevTools Protocol

01Token cost

The bill is the run, not the call.

An agent re-sends its history every turn, so what actually bills is the whole transcript. Four real scenarios, six turns each, driven against live sites through grip’s own click and type calls.

End to end, cumulative prompt tokensEnd to end, cumulative prompt tokens: ~18x

16.9x - 18.4x across repeat runs

fewer prompt tokens across a 6-turn run. Median of the per-scenario ratios, not a ratio of medians. Not a decimal, because the benchmark drives live pages and they change under it.

Verdicts are keyed on the peak single prompt, not the cumulative: no single request ever carries the cumulative figure. A naive agent that dumps outerHTML cannot put the English Wikipedia article on HTML into a 200k-token context even once, before any history at all. The largest single raw-HTML observation across 22 runs was 373,479 tokens; the largest grip observation was 27,489.

Cumulative prompt tokens across a 6-turn runfour scenarios, lightest page first
Scenario
Peak prompt, raw
Peak prompt, grip
Run reduction
form-fill
3,519
602
4.6x
hackernews
84,454
7,406
7.5x
pythondocs
311,129
8,586
29.2x
wikipedia
719,034
21,344
27.7x

Each chart point is a separate complete run, not a moment in time. The x-axis is ordered by how much raw HTML the scenario costs, so the grip curve reads flat at this scale rather than zero: it runs from 2,711 to 95,573 tokens while raw HTML runs from 12,504 to 2,648,910.

Per scenario the end-to-end reduction ranges 4.6x to 29.2x. Peak prompt is what a single request carries; no request ever carries the cumulative figure.

python benchmarks/bench_agent_ab.py, run of 2026-08-10. tiktoken cl100k_base. Full method, defect log and repeat-run variance in benchmarks/RESULTS_AB.md.

02Breakdown

Where the ~18x comes from.

Three mechanisms, measured separately because they behave differently. Compression is the broad win. Pruning is what keeps a long transcript from growing quadratically. The delta is the deep win, and only on the turns where an agent stays on one page.

REDUCTION BY MECHANISM, MEDIAN OF THE PER-SCENARIO RATIOS
Compression
11.3x2.9x to 20.1xgrip snapshot vs raw HTML, per turn
The broad win, and the one the headline is dominated by. Any serious accessibility-tree tool gets some version of it.
Delta
1.0x1.0x to 8.8xvs re-sending the full snapshot, per turn
A median of 1.0x because it only fires on same-document turns, and three of the four scenarios had at most two of six. Deep where it does fire.
Pruning
1.5x1.0x to 1.9xsuperseded page states dropped, cumulative
A separate mechanism from the delta. This is what removes the quadratic transcript term on navigation-heavy runs.

Compression is measured against raw HTML, which is the right baseline if your agent would otherwise put the DOM in the prompt. Against naively tag-stripped text the reduction is only ~1.4x (0.5x to 3.7x, evaluation/, the 23 pages where both arms succeeded), because most of what grip removes is markup rather than words. Use whichever baseline matches what you already send.

Every figure is a median of the per-scenario ratios with its full range beside it. The end-to-end number is not the three multiplied together: they act on different parts of the transcript and overlap. Source: benchmarks/RESULTS_AB.md.

03The delta

When an agent works inside one page, it stops paying to look twice.

Filling a form, driving an SPA, stepping through a wizard: on a same-document turn grip sends only what changed. Of the 24 turns in the benchmark, 8 were same-document, and on those a repeat observation cost a median 9.1x less than re-sending the snapshot.

range 0.5x to 175x

Why the median across a whole run is only 1.0x

build_delta returns None on a URL change, so on a navigation turn grip sends a full snapshot by design. How often the delta gets to run is a property of the task, not of the compression.

The 0.5x end of that range was a defect, and it is guarded now

On one turn in this run the delta cost more than the full snapshot it replaced. build_delta decided "same document" from a URL that can lag the document; when it lagged, grip diffed two unrelated DOM states and emitted a wholesale replacement. It fired in 6 of 22 runs. Two guards now bound it: a delta that is not meaningfully smaller than the full snapshot loses to it at the point the payload is chosen, and a restamped-document check compares the elements behind shared handles, since a handle stamped per document names a different element after a restart.

Median of the per-turn ratios, with the full observed range. The top of the range comes from large content pages; the single-digit end is a 13-element form whose full snapshot is under 200 tokens to begin with, so there is little left to save. Source: benchmarks/RESULTS_AB.md.

04Task success

grip lost the first run. Then it fixed the reason and won the re-run.

Not a token count this time: a real model in the loop, scored on whether a 30-task corpus of forms, SPAs and multi-step wizards actually got done.

30/30

grip, post-fix (100%)

24/30

browser-use (80.0%)

20/30

grip, first run (66.7%) — before the fix

Category
grip
browser-use
form
10/10
10/10
SPA
10/10
7/10
wizard
10/10
7/10

The SPA shutout, and the fix

The first full run: grip lost, 20/30 against browser-use's 24/30, on a 0/10 SPA shutout. gripIsCandidate() (grip/cdp/shadow.py) only admits elements with an interactive tag or ARIA role to the snapshot, so the SPA fixtures' non-semantic <div> click targets never got a ref to click. The fix (commit 2886d34, grip/page.py) adds a bounded DOMDebugger.getEventListeners probe, capped at 2 seconds, that only trusts a real click listener. grip was then re-run in full on the fixed code — the numbers above are that re-run.

Read this before the 100%

This is a limited-credibility result, not an independent audit. The fixtures are synthetic and self-hosted in grip's own repo, and the fix that produced grip's score was developed after seeing these exact failures — it is a general mechanism (any element with a click listener), not a fixture-specific patch, but it has only been validated against the fixtures it was built to pass. Both arms ran through headless `claude -p` CLI sessions, each paying a fixed session overhead on top of token cost, so the cost figures are not comparable to real API pricing.

On the 24 tasks both arms completed, grip ran a median 19.13x faster (range 1.65x to 49.95x) and, on the 22 with a recorded cost, a median 7.17x cheaper (range 0.85x to 21.80x) — as billed through the CLI path both arms used, not content-only API pricing. Source: benchmarks/RESULTS_LLM_LOOP.md.

04The API

Describe the element. Not the selector.

grip resolves “Go” or “search” against the indexed snapshot, traverses shadow DOM without special-casing, and raises typed errors your loop can branch on instead of strings it has to parse.

import asyncio
from grip import Browser
async def main():
async with Browser(headless=True) as browser:
page = await browser.open("https://news.ycombinator.com")
snapshot = await page.snapshot()
print(snapshot.text_content) # readable page text
print(snapshot.elements) # interactive elements only
print(snapshot.tokens_estimated) # what this turn will cost
asyncio.run(main())

06The CLI

Six commands, zero new dependencies.

`grip` ships as a console script on top of stdlib argparse — no extra install, no framework. It is the fastest way to see what an agent sees before writing a line of Python.

$ grip open URL
$ grip snapshot URL
$ grip read URL
$ grip screenshot URL -o out.jpg
$ grip run GOAL --url URL
$ grip doctor

Help text quoted from grip/cli.py’s argparse definitions, so this list cannot say more than the CLI actually does. `--json` on the top-level parser switches any of these to machine-readable output.

07Capabilities

Built for the loop, not for the test suite.

Everything here exists because an agent needed it mid-run: a page that changed under it, a component in a shadow root, an error it had to branch on.

Fuzzy element matching

page.click("Go") resolves against the indexed snapshot. No selectors to write, and none to fix when the markup moves.

Pure CDP

Straight onto the Chrome DevTools Protocol. No Playwright, no Puppeteer, no wrapper binary underneath.

Shadow DOM, fully traversed

Web components and custom elements surface in the same snapshot as everything else.

Typed errors with a recovery

ELEMENT_STALE, RATE_LIMITED, AUTH_REQUIRED and the rest arrive as values with a suggested action, not strings to parse.

Read mode

read() isolates the main content and keeps the heading trail on every block, so a claim can be cited back to a location.

Concurrent pages, and a trace of all of them

Every open() gets its own tab and its own CDP connection, so pages run in parallel. Every action is recorded with its timing and token cost and can be written out as a JSONL audit log.

File upload and download

upload() resolves a file input the same way click() resolves a button, and enable_downloads() redirects a page's downloads to a directory grip watches instead of dropping them in the OS default.

Real <select> support

select() matches an option by visible text first, then its value attribute, then a unique substring, and dispatches input/change — the same ladder click() and type() use for everything else.

Fail-closed by default

NavigationPolicy blocks http(s) to private, loopback and link-local addresses, and the cloud metadata endpoints specifically, before a page ever loads. Private, file:// and popup access are opt-in per Browser, not on by default.

Session persistence across runs

save_session()/load_session() carry cookies and localStorage together, so a login survives closing the browser and starting a new one.

08Hardening

Shipped in 0.8.0.

A security and correctness pass across the whole surface: silent failures the agent was never told about, snapshot gaps, and everything MCP needed to stop dying on startup.

  • Silent failures, fixed

    click() reported success on a disabled, off-screen or overlay-covered element; it now hit-tests the point and names the occluder instead. type() bypassed React's and Vue's own value trackers and fired no key events, so a controlled input never saw the change. Every action snapshotted before the page had settled, so a click that navigated could return the pre-click page. page_error and prompt_injection were computed and then thrown away, so a blocked agent was told nothing.

  • Security

    A typed password no longer reaches snapshot text, the model, or trace output. A page-authored element handle can no longer collide with grip's own or hijack selector resolution. A download landing outside the directory passed to enable_downloads() is dropped instead of returned.

  • Correctness

    click("Save") no longer matches "Save draft" — an exact match now wins outright over a fuzzy one. A stale ref from a previous document is rejected instead of silently resolving to whatever now sits at that index. A failed navigation (DNS, connection refused, timeout) no longer reports as success, and a browser crash is no longer reclassified as element-not-found, which used to loop an agent back into a dead connection.

  • Newly visible

    Element state — disabled, required, checked, selected, value — is in the snapshot now, along with scroll position and page height. iframes surface as rows, closed shadow roots are readable (attachShadow is patched before navigation), and canvas and labelled SVG are candidates. Comboboxes are recognized as a distinct control. Inputs whose label is only sibling text — the httpbin case, previously addressable by ref alone — now resolve a label through a fallback chain: label-for or wrapping label, aria-label, placeholder, title, sibling text, then a humanized name or id.

  • New capabilities

    JS dialogs are handled by policy instead of freezing the tab until timeout. wait_for(), hover() and scroll() (targeting the nearest scrollable ancestor) are new, and select() falls back to an open/re-snapshot/pick sequence for non-native comboboxes. A cookie-consent banner is dismissed conservatively before the caller's own snapshot, and a file chooser or popup can be intercepted and adopted. Viewport, device emulation and permission control — notifications and geolocation denied by default — are configurable per Browser.

  • MCP server

    grip-mcp used to die on every call to any tool if an LLM SDK wasn't installed; the adapter is now resolved lazily, only for run. press, upload, links, popups_blocked, wait_for, hover and scroll are exposed, error-recovery hints reach the client, screenshot returns an image block instead of base64-as-text, and overlapping tool calls can no longer act on the wrong tab.

  • Robustness

    The Chrome process and its temp profile no longer leak when kill() times out. The CDP timeout, previously fixed at 30 seconds, is overridable. The trace no longer grows unbounded, and Fetch.enable no longer pauses every subresource to police navigation.

483 unit tests and 111 integration tests against real Chrome pass on main; ruff and mypy are clean.

09MCP server

grip as a stdio MCP server.

`grip-mcp` exposes 19 tools over stdio: open, goto, snapshot, click, type, select, read, screenshot, run, list_tabs, switch_tab, close_tab, press, upload, links, popups_blocked, wait_for, hover and scroll. Eighteen of them need no LLM key at all — only `run` does.

Claude CodeClaude DesktopCursor
$ pip install "grip-browser[mcp]"
$ claude mcp add grip -- grip-mcp

One browser, one active page, per server process — there is no multi-session registry. Run multiple `grip-mcp` processes for concurrent sessions. Source: docs/mcp.md.

10Limits

What grip will not do for you.

The README and the security policy are candid about the edges of this library. A landing page that quietly widened them would be the least useful thing on the site.

unmeasured means unclaimed
  • Challenges are detected, not defeated

    grip classifies checkbox, Turnstile, slider, image-grid, text and invisible challenges from the DOM, and attempts checkbox, Turnstile and slider in-process. It reports "solved" only when it can verify the outcome. Image-grid and text challenges come back to your model with a screenshot. No third-party solving service. Solve rates against real anti-bot backends are still unmeasured: 0.8.1 added a benchmark over 26 local fixtures, which is evidence that grip finds the right element and dispatches the right interaction, not that it defeats anything. Building it found that the slider solver had never worked at all — the track selector matched the drag handle itself, so every attempt moved zero pixels. Cloudflare's public test sitekeys short-circuit the widget, so production Turnstile's click path remains untested here.

  • Fingerprint parity: partly a real ceiling, partly a claim we got wrong

    This page used to say TLS/JA3 fingerprints and headless parity sit below the DevTools Protocol and are unreachable. Half of that was wrong. grip drives real Chromium, so the TLS handshake is Chromium's own — JA3 was never a gap. And the headless surface is reachable: measured against bot.sannysoft's 57-signal table on Chrome 151, five signals failed with stealth off and none with it on. All five were user-agent related, including a stealth UA pinned to a Chrome version that was not the one running. Everything else people usually patch — plugins, languages, WebGL vendor, permissions consistency, window.chrome — already passed untouched, so no shims were added: patching a signal that already passes is how you manufacture a new tell. What genuinely cannot be fixed: navigator.userAgentData is left undefined under override rather than fabricated, a page that detects the DevTools session itself is below anything an injected script can reach, and IP reputation is an egress problem — route through a proxy.

  • The delta is guarded against costing more than the page it replaces

    It did not used to be. On a click-driven navigation where the reported URL trailed the document, grip diffed two unrelated pages and emitted 5,701 tokens where the full snapshot was 2,963, in 5 of 16 runs. grip's own benchmark caught it, not a user, and it is now guarded twice: is_worth_sending() makes a delta that is not meaningfully smaller than the full snapshot lose to it, and _is_restamped_document() compares the elements behind shared handles, because a handle stamped per document names a different element once the document restarts. The Hacker News case that motivated it scored 0.04 agreement.

  • Narrow on purpose

    Playwright and Puppeteer are broader automation frameworks with cross-browser support and huge ecosystems. grip does one thing: feed a model the smallest useful view of a page. For human-driven cross-browser E2E testing, use Playwright.

Cold-start time, memory, requests per second, challenge solve rates and any token figure for another tool are not measured here, so none of them appear anywhere on this page.

11Install

Python 3.11+ and a Chrome.

grip finds Chrome or Chromium automatically, and falls back to the Chrome for Testing build that Playwright or Puppeteer already downloaded. Set CHROME_EXECUTABLE to override.

$ pip install grip-browser
$ pip install grip-browser[anthropic]
$ pip install grip-browser[openai]
$ pip install grip-browser[gemini]

No Anthropic, OpenAI or Gemini key on hand? Pass base_url to the OpenAI adapter (or set OPENAI_BASE_URL) and point it at any OpenAI-compatible endpoint: Ollama, vLLM, LM Studio, OpenRouter.

483
unit tests pass
33
gripsearch tests pass
111
integration tests pass

Integration tests run against real Chrome over live network. ruff and mypy are clean on the same branch. Counts are for the current branch and will move, so re-run them rather than trusting the number.