Guide #9 Browser Agents 35 min read March 2026

Vision Model Browser Agent

Anthropic expanded Claude's computer use in March 2026. Perplexity launched Comet, an AI browser. Cursor Cloud gave agents their own desktops. The pattern is clear: the next generation of browser agents navigate by seeing, not by CSS selectors.

This guide builds a complete vision-based browser agent. It takes a screenshot, sends it to a vision model (Claude Sonnet 4.6, GPT-4o, or Gemini 2.5 Pro), gets back a structured action (click here, type this, scroll down), and executes it. Loop until the task is done. It works on any website — even ones built with Canvas, Shadow DOM, or heavy JavaScript that defeats traditional scraping.

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:

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.

This is not experimental

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:

  1. Perceive: Take a screenshot of the current page
  2. Reason: Send the screenshot + task description to a vision model
  3. Act: The model returns a structured action (click, type, scroll, wait, done)
  4. Execute: Perform the action in the browser
  5. Repeat: Go back to step 1
Screenshot Vision Model Action Browser Screenshot Loop until the model returns "done" or max iterations reached.

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:

python
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 = ""
ActionParametersWhen the model uses it
clickx, y coordinatesClick a button, link, dropdown, checkbox, tab
typetext to enterFill a text field, search box, textarea (clicks the field first)
scrolldirection, amountContent is below the fold, or the model needs to see more
waitsecondsPage is loading, animation in progress
navigateURLGo to a specific URL (for the first action or redirects)
doneresult messageTask is complete, with the result
failerror messageTask 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.

python — vision_browser_agent.py
"""
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

python
# 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",
))
No selectors anywhere

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

ModelProviderSpeedAccuracyCost/ScreenshotBest 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 Google Fast (1-2s) Good ~$0.002 Cheapest option. Good for simple tasks. Can struggle with complex layouts.
Start with Sonnet, upgrade to Opus when stuck

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.

python
# 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

TaskStepsModel CostBrowser CostTotal
Navigate + extract one page3-5$0.01-0.02$0.002$0.02
Login + fill 3-page form10-20$0.03-0.06$0.004$0.06
Research across 5 sites20-30$0.06-0.09$0.01$0.10
Complex workflow (15 min)30$0.09$0.01$0.10
The browser is basically free

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