The shift from selectors to seeing
For twenty years, browser automation meant writing CSS selectors. document.querySelector('.price-tag'). page.click('#submit-btn'). The script knew exactly where every element was because a human mapped the DOM beforehand.
This worked until it didn't:
- Dynamic IDs. React, Vue, and Angular generate class names like
.css-1a2b3cthat change every build. Your selectors break on every deployment. - Shadow DOM. Web components hide their internals.
querySelectorcan't reach inside. - Canvas and WebGL. Maps, charts, games, and drawing tools render to a
<canvas>element. There's no DOM to query. The content exists only as pixels. - Anti-bot measures. Sites randomize their DOM, add invisible elements, and detect Playwright-style interaction patterns.
- Unknown layouts. If your agent encounters a portal it's never seen, there are no selectors to write. With vision, it looks at the page and figures it out.
Vision-based agents bypass all of these. They see the page the same way a human does — as pixels. They identify buttons by reading the text on them, not by matching a CSS class. They find form fields by their labels, not by their name attribute. They understand page layout spatially, not structurally.
Anthropic's Claude computer use has been in public beta since October 2024 and expanded significantly in March 2026. OpenAI Atlas, Perplexity Comet, and Cursor Cloud agents all use this pattern. It's the mainstream approach for browser agents in 2026, not a research curiosity.
The perception-action loop
Every vision-based browser agent runs the same core loop:
- Perceive: Take a screenshot of the current page
- Reason: Send the screenshot + task description to a vision model
- Act: The model returns a structured action (click, type, scroll, wait, done)
- Execute: Perform the action in the browser
- Repeat: Go back to step 1
The model sees what a human would see. It decides what a human would do. The browser executes it. The loop continues until the task is complete, or the agent decides it can't proceed.
Action types
The model needs a structured vocabulary of actions it can take. Keep it small and precise:
from dataclasses import dataclass from typing import Optional, Literal @dataclass class BrowserAction: """A single action the agent can take in the browser.""" type: Literal["click", "type", "scroll", "wait", "navigate", "done", "fail"] # For click: pixel coordinates x: Optional[int] = None y: Optional[int] = None # For type: text to enter text: Optional[str] = None # For scroll: direction and amount direction: Optional[Literal["up", "down"]] = None amount: int = 300 # pixels # For navigate: URL url: Optional[str] = None # For done/fail: result message result: Optional[str] = None # Reasoning (for debugging) reasoning: str = ""
| Action | Parameters | When the model uses it |
|---|---|---|
click | x, y coordinates | Click a button, link, dropdown, checkbox, tab |
type | text to enter | Fill a text field, search box, textarea (clicks the field first) |
scroll | direction, amount | Content is below the fold, or the model needs to see more |
wait | seconds | Page is loading, animation in progress |
navigate | URL | Go to a specific URL (for the first action or redirects) |
done | result message | Task is complete, with the result |
fail | error message | Task cannot be completed (blocked, CAPTCHA, error page) |
The agent: full implementation
Here's the complete agent. It connects to a cloud browser, runs the perception-action loop, and returns a result.
""" Vision-based browser agent. Takes screenshots, sends to a vision model, executes actions, loops until done. Works on any website. """ import asyncio, base64, json, time from typing import Optional import anthropic import httpx from playwright.async_api import async_playwright SANDBOX_URL = "https://sandbox.dev.ab0t.com" API_KEY = "ab0t_sk_live_YOUR_KEY" HEADERS = {"Authorization": f"Bearer {API_KEY}"} # Vision model client model_client = anthropic.Anthropic() MODEL = "claude-sonnet-4-6" # Best price/performance for vision tasks # Agent configuration MAX_STEPS = 30 # Maximum perception-action loops VIEWPORT_W = 1280 # Browser viewport width VIEWPORT_H = 900 # Browser viewport height SYSTEM_PROMPT = """You are a browser automation agent. You can see a screenshot of a web page and must decide what action to take next to complete the user's task. AVAILABLE ACTIONS (respond with exactly one JSON object): {"type": "click", "x": 450, "y": 320, "reasoning": "Clicking the Sign In button"} {"type": "type", "text": "hello world", "reasoning": "Typing search query into the search box"} {"type": "scroll", "direction": "down", "amount": 500, "reasoning": "Scrolling to see more results"} {"type": "wait", "reasoning": "Page is loading, waiting for content"} {"type": "navigate", "url": "https://example.com", "reasoning": "Going to the target site"} {"type": "done", "result": "Found 3 products matching the criteria: ...", "reasoning": "Task complete"} {"type": "fail", "result": "Site shows a CAPTCHA that I cannot solve", "reasoning": "Cannot proceed"} RULES: - Always respond with a single JSON object. No other text. - Click coordinates are relative to the viewport (top-left is 0,0). - For "type": first click the input field, THEN type in the next step. One action per response. - If the page looks the same after an action, try a different approach. - After 3 failed attempts at the same thing, declare "fail". - When the task is complete, use "done" with the result data. - Include "reasoning" in every action so the user can debug your decisions. VIEWPORT: {width}x{height} pixels.""" async def take_screenshot(page) -> str: """Take a screenshot and return as base64.""" screenshot_bytes = await page.screenshot( type="png", full_page=False, # Only the viewport — matches what the model should see ) return base64.b64encode(screenshot_bytes).decode() def ask_vision_model( task: str, screenshot_b64: str, history: list[dict], ) -> dict: """Send screenshot to vision model, get back an action.""" messages = [] # Include recent history (last 5 steps) for context for h in history[-5:]: messages.append({ "role": "user", "content": [ {"type": "image", "source": { "type": "base64", "media_type": "image/png", "data": h["screenshot"], }}, {"type": "text", "text": f"Step {h['step']}: What action should I take?"}, ], }) messages.append({ "role": "assistant", "content": json.dumps(h["action"]), }) # Current screenshot messages.append({ "role": "user", "content": [ {"type": "image", "source": { "type": "base64", "media_type": "image/png", "data": screenshot_b64, }}, {"type": "text", "text": f"Task: {task}\n\nWhat action should I take on this page?"}, ], }) response = model_client.messages.create( model=MODEL, max_tokens=512, system=SYSTEM_PROMPT.format(width=VIEWPORT_W, height=VIEWPORT_H), messages=messages, ) # Parse the JSON action from the response text = response.content[0].text.strip() # Handle markdown code blocks if text.startswith("```"): text = text.split("```")[1] if text.startswith("json"): text = text[4:] return json.loads(text) async def execute_action(page, action: dict) -> None: """Execute a browser action.""" action_type = action["type"] if action_type == "click": x, y = action["x"], action["y"] await page.mouse.click(x, y) await page.wait_for_timeout(1000) # Let the click register elif action_type == "type": await page.keyboard.type(action["text"], delay=50) await page.wait_for_timeout(500) elif action_type == "scroll": direction = action.get("direction", "down") amount = action.get("amount", 300) delta = amount if direction == "down" else -amount await page.mouse.wheel(0, delta) await page.wait_for_timeout(500) elif action_type == "wait": await page.wait_for_timeout(3000) elif action_type == "navigate": await page.goto(action["url"], timeout=30000) await page.wait_for_load_state("networkidle") # "done" and "fail" are handled by the caller async def run_vision_agent( task: str, start_url: str = "", max_steps: int = MAX_STEPS, ) -> dict: """Run a vision-based browser agent to complete a task. Returns: {"success": bool, "result": str, "steps": int, "cost_estimate": float} """ # 1. Launch a cloud browser async with httpx.AsyncClient(timeout=60) as http: resp = await http.post( f"{SANDBOX_URL}/api/browsers", headers=HEADERS, json={"browser_type": "chrome", "idle_timeout_minutes": 15}, ) browser_info = resp.json() container_id = browser_info["container_id"] cdp_url = browser_info["cdp_url"] print(f"Browser: {container_id}") print(f"Watch live: {browser_info.get('access_url', '?')}") await asyncio.sleep(20) # Wait for container ready history = [] result = {"success": False, "result": "Max steps reached", "steps": 0} async with async_playwright() as pw: browser = await pw.chromium.connect_over_cdp(cdp_url) context = browser.contexts[0] page = context.pages[0] if context.pages else await context.new_page() await page.set_viewport_size({"width": VIEWPORT_W, "height": VIEWPORT_H}) # Navigate to start URL if provided if start_url: await page.goto(start_url, timeout=30000) await page.wait_for_load_state("networkidle") # 2. Perception-action loop for step in range(1, max_steps + 1): # Perceive screenshot_b64 = await take_screenshot(page) # Reason action = ask_vision_model(task, screenshot_b64, history) action_type = action.get("type", "?") reasoning = action.get("reasoning", "") print(f" Step {step}: {action_type} — {reasoning}") # Record history history.append({ "step": step, "screenshot": screenshot_b64, "action": action, }) # Check for terminal actions if action_type == "done": result = { "success": True, "result": action.get("result", "Task completed"), "steps": step, } break elif action_type == "fail": result = { "success": False, "result": action.get("result", "Task failed"), "steps": step, } break # Act try: await execute_action(page, action) except Exception as e: print(f" Action failed: {e}") # Don't crash — let the model see the result and adapt # Final screenshot await page.screenshot(path="final-state.png", full_page=True) await browser.close() # Cleanup async with httpx.AsyncClient(timeout=10) as http: await http.delete(f"{SANDBOX_URL}/api/containers/{container_id}", headers=HEADERS) # Cost estimate: ~$0.003 per screenshot analysis (Sonnet 4.6) result["cost_estimate"] = round(result.get("steps", 0) * 0.003 + 0.004, 4) return result
Using the agent
# Example 1: Research task result = asyncio.run(run_vision_agent( task="Go to news.ycombinator.com. Find the top story. Click into it. " "Read the first few paragraphs. Tell me what the article is about.", start_url="https://news.ycombinator.com", )) print(f"Success: {result['success']}") print(f"Result: {result['result']}") print(f"Steps: {result['steps']}") print(f"Cost: ${result['cost_estimate']}") # Example 2: Price extraction result = asyncio.run(run_vision_agent( task="Go to vercel.com/pricing. Extract all plan names and their monthly prices. " "Return them as a structured list.", start_url="https://vercel.com/pricing", )) # Example 3: Form filling result = asyncio.run(run_vision_agent( task="Go to the login page. Enter email 'test@example.com' and password 'demo123'. " "Click Sign In. Report whether login succeeded.", start_url="https://demo-portal.example.com/login", ))
Notice that the task descriptions are in plain English. The agent figures out what to click by looking at the page, not by knowing the CSS selectors in advance. When Vercel redesigns their pricing page, the agent still works. When the demo portal moves the login button, the agent finds it.
Choosing the right vision model
| Model | Provider | Speed | Accuracy | Cost/Screenshot | Best For |
|---|---|---|---|---|---|
| Claude Sonnet 4.6 | Anthropic | Fast (1-2s) | Excellent | ~$0.003 | Best overall: fast, accurate, good at spatial reasoning. Default choice. |
| Claude Opus 4.6 | Anthropic | Slower (3-5s) | Best | ~$0.015 | Complex tasks requiring deep reasoning. Multi-step form workflows. When Sonnet fails. |
| GPT-4o | OpenAI | Fast (1-2s) | Very Good | ~$0.004 | Good alternative to Sonnet. Slightly better at reading small text in screenshots. |
| Gemini 2.5 Pro | Fast (1-2s) | Good | ~$0.002 | Cheapest option. Good for simple tasks. Can struggle with complex layouts. |
Most tasks complete in 5-15 steps with Sonnet 4.6 at $0.003/step = $0.015-$0.045 in model costs. If the agent gets stuck or loops, retry with Opus 4.6 which reasons more carefully. Use Gemini for high-volume, simple tasks where cost matters more than accuracy.
Optimizations for production
Reduce screenshot resolution
1280x900 is a good default, but for simple pages you can go smaller. 1024x768 reduces token count by ~30%. For pages with fine text, keep 1280x900 or go to 1440x900.
Add DOM context alongside screenshots
The hybrid approach: send both the screenshot AND a simplified DOM tree. The model uses the screenshot for spatial understanding and the DOM for precise targeting. This produces more reliable click coordinates.
# Extract a simplified DOM alongside the screenshot dom_summary = await page.evaluate("""() => { const interesting = document.querySelectorAll( 'button, a, input, select, textarea, [role="button"], [onclick]' ); return Array.from(interesting).slice(0, 50).map(el => { const rect = el.getBoundingClientRect(); return { tag: el.tagName.toLowerCase(), text: el.textContent?.trim()?.slice(0, 80), type: el.type || '', name: el.name || '', x: Math.round(rect.x + rect.width/2), y: Math.round(rect.y + rect.height/2), visible: rect.width > 0 && rect.height > 0, }; }).filter(e => e.visible); }""") # Add to the prompt: "Here are the interactive elements and their positions: ..." # This helps the model click more accurately because it has both # visual (screenshot) and structural (DOM) information.
Limit history window
Sending all previous screenshots is expensive. Keep only the last 3-5 steps in the conversation. The model doesn't need to remember step 1 when it's on step 15.
Cache screenshots for repeated pages
If the agent visits the same page multiple times (e.g., returning to a search results page), cache the screenshot and skip the model call for familiar states.
Short-circuit for known patterns
If you know a site well, add pattern matching before the vision model. "If the URL contains '/login', use the fast selector-based login. Only fall back to vision for unknown pages." This is the hybrid approach used in production.
What it costs
| Task | Steps | Model Cost | Browser Cost | Total |
|---|---|---|---|---|
| Navigate + extract one page | 3-5 | $0.01-0.02 | $0.002 | $0.02 |
| Login + fill 3-page form | 10-20 | $0.03-0.06 | $0.004 | $0.06 |
| Research across 5 sites | 20-30 | $0.06-0.09 | $0.01 | $0.10 |
| Complex workflow (15 min) | 30 | $0.09 | $0.01 | $0.10 |
At $0.1/hr, the browser costs ~$0.005 for a 3-minute task. The vision model costs 5-30x more. Optimize model calls (smaller screenshots, fewer history messages, hybrid selector/vision), not browser time.
Limitations and when not to use this
Don't use vision for sites with stable DOMs
If a site's HTML structure never changes (your own internal tools), selector-based automation is faster and cheaper. Vision adds 1-3 seconds per step for model inference. Use vision as a fallback, not the default.
CAPTCHAs remain unsolved
Vision models can sometimes solve simple text CAPTCHAs, but reCAPTCHA v3 and hCaptcha are designed to detect bots. Use persistent cookies to skip CAPTCHAs on repeat visits.
Speed: 2-5 seconds per action
Each step requires a model API call (1-3 seconds) plus the browser action (0.5-2 seconds). A 15-step task takes 45-75 seconds. For latency-sensitive workflows, use selector-based automation with vision fallback.
Cost at extreme scale
100 vision agents each taking 20 steps = 2,000 model calls = ~$6. For high-volume, repetitive tasks (daily runs on the same 100 sites), invest in selector-based automation for the top 80% and use vision for the long tail.
What's next
Give your agent eyes
Cloud browsers + vision models. Any website. Any layout. No selectors needed.
Get Started Free