Browser-Use, Playwright agents, Steel, accessibility-tree extraction, WebArena/Mind2Web evals, captcha/anti-bot, stateful sessions, pricing, and failure recovery.
Three tiers of abstraction have emerged for browser-based LLM agents. They differ in where the DOM/screenshot extraction happens, which models they support, and how much infrastructure they manage for you.
browser-use v0.2.x (MIT, Jan 2025) wraps Playwright with a hybrid AXTree + screenshot perception layer. 1000+ GitHub stars within two weeks of release.
click_element(index=7), input_text(index=3, text="...")pip install browser-use; model-agnostic (OpenAI, Anthropic, Gemini, local)Roll your own scaffolding around Playwright (Microsoft, 2020). Full control; no abstraction overhead.
playwright.chromium.launch(headless=True)page.accessibility.snapshot() for the AXTree<script> tags or evaluate JS for richer extractionSteel (steel.dev, 2024) is a remote browser-as-a-service: managed Chromium instances accessible via CDP, Playwright, or Puppeteer — no local browser, no CAPTCHA infrastructure to manage.
POST /sessions → session ID + CDP endpointUse Browser-Use for quick prototyping with any major LLM. Use raw Playwright when you need fine-grained CDP control, custom JS injection, or non-standard browser configurations. Use Steel when you need managed residential proxies, persistent sessions across runs, or you don't want to manage a Chromium fleet yourself.
The quality of the agent's world-model depends entirely on how well the page is reduced to a form the LLM can plan over. Four strategies are in common use, from least to most sophisticated:
Naive. Grabs document.body.innerHTML or the Playwright content() call. Typical page: 200–800 KB raw, 30–150 KB stripped. Token count after stripping scripts and styles: 8 000–60 000. Almost always too large for a single context window. Only viable for very small pages or if you're summarising the page separately.
async def get_interactive_elements(page):
return await page.evaluate("""() => {
const selectors = [
'a[href]', 'button', 'input', 'select', 'textarea',
'[role=button]', '[role=link]', '[role=checkbox]',
'[role=combobox]', '[tabindex]:not([tabindex="-1"])'
];
return selectors
.flatMap(s => [...document.querySelectorAll(s)])
.filter(el => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0; // visible only
})
.slice(0, 150) // hard cap
.map((el, i) => ({
index: i,
tag: el.tagName.toLowerCase(),
type: el.type || null,
text: (el.innerText || el.value || el.placeholder || '').trim().slice(0, 120),
href: el.href || null,
rect: el.getBoundingClientRect()
}));
}""")
cdp = await page.context.new_cdp_session(page)
tree = await cdp.send("Accessibility.getFullAXTree")
# tree['nodes'] — list of dicts with role, name, nodeId, backendDOMNodeId
interactive_roles = {
"button", "link", "textbox", "combobox", "checkbox",
"radio", "menuitem", "tab", "listbox", "searchbox"
}
filtered = [
n for n in tree["nodes"]
if n.get("role", {}).get("value") in interactive_roles
and not n.get("ignored", False)
]
# Typical result: 20–100 nodes, ~1.5 KB JSON — fits easily in context
Draw numbered bounding boxes on the screenshot (using Canvas API or Pillow post-capture) for each interactive element. Pass the annotated image + the index→element mapping. Model says click(mark=12). Avoids raw co-ordinate hallucination. Adds ~200 ms overhead for overlay generation.
Browser-Use combines strategies 2 and 4: it builds an interactive-element list (JavaScript enumeration), annotates the screenshot with coloured bounding boxes, and sends both the annotated image and the element list to the model. The model is prompted to reference elements by index. This is why Browser-Use consistently outperforms screenshot-only baselines on forms and multi-select widgets.
No single perception strategy dominates across all web tasks. The right choice depends on the page's structure and the nature of the task.
| Scenario | Best strategy | Why |
|---|---|---|
| Simple web form (login, checkout) | AXTree / element list only | All needed info is in roles + labels; no visual parsing needed; 5× cheaper than screenshot |
| Data-heavy table / dashboard | DOM extraction (innerText per cell) | Tables render well as structured text; screenshot wastes tokens on cell borders and styling |
| CAPTCHA, image-based ARIA, CSS-only buttons | Screenshot required | AXTree shows role="img" name=""; only pixels reveal the visual content |
| Canvas-rendered application (Figma, Google Maps) | Screenshot required | DOM contains almost nothing useful; canvas content is entirely pixel-based |
| Single-page app with good ARIA semantics | AXTree with screenshot fallback | Modern SPA frameworks (React, Vue with aria-*) produce usable trees; screenshot for disambiguation |
| Legacy enterprise portal | Screenshot + SOM overlay | Broken/absent a11y tree; pixel co-ordinates are the only reliable anchor |
For pages that update asynchronously (stock tickers, live chat, progress bars), neither a static screenshot nor a one-shot DOM extraction is sufficient. The correct pattern is a mutation observer: inject a small JS shim via page.evaluate that watches MutationObserver events and posts them to a queue. The agent polls the queue rather than re-capturing the full page, giving 50–80% reduction in per-step token cost for long-running observations.
Browser-focused evals differ from the full-desktop OSWorld in that they run against controlled web environments. This makes ground-truth verification easier but raises contamination concerns for any model trained on public web data.
812 tasks across five self-hosted web clones: Reddit (Postmill), GitLab, OneStopShop (e-commerce), CMS (Content Manager), Maps (OpenStreetMap clone).
Evaluation: rule-based functional checks — did the item get added to cart? did the GitLab issue get created with the right label? — augmented by string-match and regex verifiers.
2025 SOTA: ~35.8% (Claude 3.5 Sonnet + custom scaffolding). Human baseline: ~84%.
Setup: Docker Compose; five containers; ~12 GB download; requires manual seed scripts per task set. See web-arena-bench/webarena on GitHub.
2000 tasks from 137 real websites across 31 domains. Tasks are recorded human trajectories; agents re-play against static snapshots, not live pages.
Evaluation: step-level action match (is the predicted element in the gold element set?) plus task success rate.
2025 SOTA: ~40% task success (Qwen-VL + fine-tune on Mind2Web train split). GPT-4V (zero-shot): ~18%.
Limitation: static snapshots mean the agent never needs to handle async page loads, infinite scroll, or dynamic state — easier than real browsing but useful for model development.
| Eval | Live execution? | Datasets | Verifier type | Freshness risk |
|---|---|---|---|---|
| WebArena | Yes (self-hosted) | 812 tasks, 5 app clones | Rule-based + string match | Low — self-hosted clones don't change |
| Mind2Web | No (static replay) | 2000 tasks, 137 real sites | Element match at each step | Medium — snapshots from 2023 may lag real sites |
| VisualWebArena | Yes | 910 tasks, adds image-grounding tasks | Rule-based + VQA judge | Low |
| WorkArena | Yes (ServiceNow sandbox) | 33 000 tasks | API-verified state changes | Low |
35% success on WebArena sounds poor but the tasks are genuinely hard: “find all open GitLab issues assigned to the user with the most closed issues, and change their username” requires chaining 6–10 navigation + form steps with zero error margin. A single wrong click fails the entire task. Score is task-success, not step-accuracy — step accuracy for the same model is typically 65–75%.
Browser agents inevitably collide with anti-bot defences. Understanding the technical layers helps both in mitigating them legitimately and in knowing when to refuse.
from playwright.async_api import async_playwright
from playwright_stealth import stealth_async
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=["--disable-blink-features=AutomationControlled"]
)
page = await browser.new_page()
await stealth_async(page) # patches navigator.webdriver, plugins, CDP artefacts
# Add mouse-movement noise before clicks
await page.mouse.move(x + random.gauss(0, 3),
y + random.gauss(0, 3))
await asyncio.sleep(random.uniform(0.08, 0.35))
await page.mouse.click(x, y)
Bypassing a site's terms of service with automation is a legal risk (Computer Fraud and Abuse Act in the US; Computer Misuse Act in the UK). The practical line: use browser agents to automate your own account's tasks. CAPTCHA-solving at scale for scraping or credential-stuffing crosses into illegal territory. Steel's residential proxies and TLS fingerprint spoofing are dual-use tools — the ethics lie in the intent and ToS, not the technique.
Most non-trivial web tasks require authentication. The three main patterns for session persistence differ in security, portability, and implementation complexity.
Playwright's context.storage_state(path="state.json") serialises cookies + localStorage + sessionStorage to JSON. Reload with browser.new_context(storage_state="state.json"). Simple; human-readable; works across sessions.
Risk: the JSON file contains live auth tokens. Treat as a secret; rotate regularly.
For services with OAuth 2.0 (Google, GitHub, Slack), have the agent complete the PKCE flow once manually, then persist only the refresh token. Re-mint access tokens programmatically; never screen-scrape the OAuth redirect.
Steel advantage: its persistent-profile feature stores cookies server-side, associated with a session ID — no local secret file needed.
Inject credentials at agent launch via environment variables or a vault (HashiCorp Vault, AWS Secrets Manager). The agent calls page.fill("#username", os.environ["SITE_USER"]) directly rather than storing credentials in the browser profile.
Never pass credentials via the LLM context — they may appear in logs, traces, or be extracted by prompt injection.
# --- Save after manual login ---
context = await browser.new_context()
page = await context.new_page()
await page.goto("https://example.com/login")
# ... perform login manually or via the agent ...
await context.storage_state(path="example_session.json")
await context.close()
# --- Restore on next run ---
context = await browser.new_context(
storage_state="example_session.json"
)
page = await context.new_page()
await page.goto("https://example.com/dashboard")
# Already authenticated; no login page shown
Browser-Use v0.2 supports explicit multi-tab management: open_tab(url), switch_tab(tab_id), close_tab(tab_id). This is essential for tasks that require opening a search result in a new tab while keeping the search results page for backtracking. Each tab shares the same browser context (and therefore the same cookies), but has its own navigation history and scroll position.
Browser-agent costs have three components: LLM inference (per token), browser infrastructure (per minute), and operator overhead (retries, human-in-the-loop interrupts). Understanding each matters for cost-effective deployment.
| Component | Typical cost | Notes |
|---|---|---|
| LLM inference — GPT-4o screenshot mode | ~$0.0019 / step (vision input) ~$0.006 / step (output ~200 tokens) |
20-step task ≈ $0.16 in inference; screenshot + action output |
| LLM inference — Claude 3.5 Sonnet | ~$0.0038 / step (vision input) ~$0.015 / step (output ~200 tokens) |
Haiku 3.5 cuts input 10× to ~$0.0004/step; lower accuracy |
| Steel remote browser | ~$0.10 / minute (2025 pricing) | 20-step task takes ~3–8 min ≈ $0.30–$0.80 in browser time |
| Self-hosted Playwright (AWS EC2 t3.medium) | ~$0.0416 / hour ≈ $0.0007 / minute | 100× cheaper than managed — but you pay for ops, proxy infra, CAPTCHA handling |
| Residential proxy (Bright Data / Oxylabs) | $8–$15 / GB traffic | Most tasks use <5 MB of traffic — negligible; relevant only for data-scraping scale |
Simple form fill (5 steps, AXTree only, GPT-4o mini): ~$0.03 total. Flight search & booking (30 steps, Claude 3.5 Sonnet): ~$0.80–$1.50 self-hosted, ~$2.50–$4.00 on Steel. Competitive intelligence run (100 steps, GPT-4o + proxy): ~$3–$8. Infrastructure cost dominates at scale; model cost dominates for infrequent single tasks.
Browser agents fail in characteristic ways. Understanding the failure taxonomy is necessary before designing a recovery strategy.
| Failure mode | Cause | Detection | Recovery |
|---|---|---|---|
| Stale screenshot | Page load in progress; spinner visible | document.readyState check; AXTree loading indicators |
Wait for networkidle before capturing; retry |
| Grounding hallucination | Model invents a UI element; click misses | page.locator(sel).count() before click |
Fall back to screenshot + SOM; re-plan with fresh perception |
| Infinite scroll loop | Model scrolls without recognising page end | Hash last N screenshots; detect stable state | Check scrollHeight; break when position static |
| Auth session expiry | Cookie TTL expires; redirect to login | Monitor URL changes; detect /login pattern |
Re-authenticate from vault; resume from checkpoint |
| Rate limiting / 429 | Too many requests; server throttles | page.on("response") status monitor |
Exponential back-off with jitter; respect Retry-After |
| Prompt injection | Malicious page text hijacks agent goals | Secondary classifier on page text | Instruction hierarchy in system prompt; human gate before irreversible actions |
async def run_with_recovery(task, max_steps=40, max_retries=3):
checkpoint = None # URL + storage state at last verified good state
for attempt in range(max_retries):
try:
context = await make_context(checkpoint)
result = await run_agent(task, context, max_steps)
if result.success:
return result
# Save checkpoint at last successful sub-goal
checkpoint = await save_checkpoint(context)
except PlaywrightTimeoutError:
await asyncio.sleep(2 ** attempt) # back-off
finally:
await context.close()
raise AgentExhausted(f"Task failed after {max_retries} attempts")
Never retry a write action (form submit, purchase, message send) without confirming the previous attempt failed. Network retries on idempotent GETs are safe; retrying a POST that may have succeeded silently causes duplicates. Check the resulting page state explicitly before concluding an action failed.
playwright-stealth handles layers 1–2. Cloudflare Turnstile needs attestation or a managed proxy. Never bypass ToS protections.Deck 01 covered the architecture landscape: Anthropic Computer Use, OpenAI Operator, Gemini Mariner, action schemas, sandboxing, and full-desktop evals. This deck covered the browser-native layer in depth. Together they give you the vocabulary and engineering context to evaluate, deploy, and extend production computer-use systems.