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.
$ pip install grip-browserPAGE: Hacker NewsURL: 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.
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.
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.
- 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.
grip, post-fix (100%)
browser-use (80.0%)
grip, first run (66.7%) — before the fix
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 asynciofrom 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 doctorHelp 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.
$ pip install "grip-browser[mcp]"$ claude mcp add grip -- grip-mcpOne 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.
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.