Browsa
  1. Guides
  2. Claude computer use

Drive a cloud browser with Claude computer use.

Let Claude see and operate a real Chrome on Browsa. Playwright takes the screenshots and performs the clicks and keystrokes that Claude asks for through its computer use toolset.

  • TimeAbout 10 minutes
  • Cost1 credit per 5-minute browser, plus your model costs
  • Last tested
What we tested. On November 7, 2026 we ran this exact script against a live Browsa browser with the Anthropic API replaced by a scripted stand-in. It connected, took 1280x800 screenshots, and ran screenshot, zoom, mouse_move, cursor_position, scroll, wait, key, left_click, double_click, type and open_url calls, including following example.com's link from the keyboard. Every result carried toolset_name. We didn't call the real Claude API (no key); the request follows Anthropic's computer use docs for computer_toolset_20260801 and the anthropic Python SDK 1.17.0.

Prerequisites

  • A Browsa API key (it starts with agt_). Sign up for 100 free credits, no card.
  • Python 3.10 or newer.
  • An Anthropic API key. The script uses claude-opus-5-5.

Install

Terminal
pip install anthropic playwright browsa

Then put your keys in the environment:

Terminal
export BROWSA_API_KEY="agt_live_..."
export ANTHROPIC_API_KEY="sk-ant-..."

You don't need playwright install: the browser runs on Browsa, and Playwright only connects to it.

The script

One file. run_action() turns each toolset call into Playwright mouse and keyboard input; the loop sends the results back until Claude answers without calling a tool.

agent.py
import base64
import os

import anthropic
from browsa import Client
from playwright.sync_api import sync_playwright

BROWSA_API_KEY = os.environ["BROWSA_API_KEY"]
MODEL = "claude-opus-5-5"
WIDTH, HEIGHT = 1280, 800
START_URL = "https://example.com"
TASK = "Follow the link on this page and tell me the title of the page it opens."

TOOLS = [
    {"type": "computer_toolset_20260801"},  # screenshot, left_click, type, key, scroll, zoom, ...
    {
        "name": "open_url",
        "description": "Open a URL in the browser. The screenshot shows only the page, with no address bar.",
        "input_schema": {
            "type": "object",
            "properties": {"url": {"type": "string"}},
            "required": ["url"],
            "additionalProperties": False,
        },
        "strict": True,
    },
]

# Claude names keys like xdotool ("Return", "ctrl+a"); Playwright wants "Enter", "Control+a".
KEY_NAMES = {
    "return": "Enter", "enter": "Enter", "esc": "Escape", "escape": "Escape",
    "ctrl": "Control", "control": "Control", "alt": "Alt", "shift": "Shift",
    "super": "Meta", "cmd": "Meta", "meta": "Meta", "tab": "Tab", "space": "Space",
    "backspace": "Backspace", "delete": "Delete", "home": "Home", "end": "End",
    "page_up": "PageUp", "pageup": "PageUp", "page_down": "PageDown", "pagedown": "PageDown",
    "up": "ArrowUp", "down": "ArrowDown", "left": "ArrowLeft", "right": "ArrowRight",
}
BUTTONS = {"left_click": ("left", 1), "right_click": ("right", 1), "middle_click": ("middle", 1),
           "double_click": ("left", 2), "triple_click": ("left", 3)}


def pw_keys(combo: str) -> list[str]:
    return [KEY_NAMES.get(k.lower(), k) for k in combo.split("+")]


def image(png: bytes) -> list[dict]:
    data = base64.standard_b64encode(png).decode()
    return [{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": data}}]


def run_action(page, name: str, args: dict, mouse: dict):
    """Run one computer toolset call with Playwright. Returns tool_result content."""
    if "coordinate" in args:
        mouse["x"], mouse["y"] = args["coordinate"]

    if name == "screenshot":
        return image(page.screenshot(scale="css"))
    if name == "zoom":
        x0, y0, x1, y1 = args["region"]
        return image(page.screenshot(scale="css", clip={"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0}))
    if name in BUTTONS:
        button, count = BUTTONS[name]
        modifiers = pw_keys(args["text"]) if args.get("text") else []  # e.g. "shift" for shift+click
        for k in modifiers:
            page.keyboard.down(k)
        page.mouse.click(mouse["x"], mouse["y"], button=button, click_count=count)
        for k in reversed(modifiers):
            page.keyboard.up(k)
    elif name == "left_click_drag":
        page.mouse.move(*args["start_coordinate"])
        page.mouse.down()
        page.mouse.move(mouse["x"], mouse["y"], steps=12)
        page.mouse.up()
    elif name == "mouse_move":
        page.mouse.move(mouse["x"], mouse["y"])
    elif name == "left_mouse_down":
        page.mouse.down()
    elif name == "left_mouse_up":
        page.mouse.up()
    elif name == "cursor_position":
        return f"X={mouse['x']},Y={mouse['y']}"
    elif name == "scroll":
        page.mouse.move(mouse["x"], mouse["y"])
        step = 100 * int(args.get("scroll_amount", 3))
        dx, dy = {"up": (0, -step), "down": (0, step), "left": (-step, 0), "right": (step, 0)}[args["scroll_direction"]]
        page.mouse.wheel(dx, dy)
    elif name == "type":
        page.keyboard.type(args["text"])
    elif name == "key":
        for _ in range(int(args.get("repeat", 1))):
            page.keyboard.press("+".join(pw_keys(args["text"])))
    elif name == "hold_key":
        keys = pw_keys(args["text"])
        for k in keys:
            page.keyboard.down(k)
        page.wait_for_timeout(1000 * min(float(args["duration"]), 30))
        for k in reversed(keys):
            page.keyboard.up(k)
    elif name == "wait":
        page.wait_for_timeout(1000 * min(float(args["duration"]), 30))
    else:
        raise ValueError(f"Unsupported action: {name}")
    page.wait_for_timeout(300)  # let the page react before the next call
    return "OK"


def main():
    claude = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY
    browsa = Client(api_key=BROWSA_API_KEY)
    cloud = browsa.create_agent(ttl="5m")  # a Browsa cloud browser, 1 credit
    try:
        with sync_playwright() as p:
            browser = p.chromium.connect_over_cdp(cloud["cdp_http"], headers={"X-API-Key": BROWSA_API_KEY})
            context = browser.contexts[0]
            page = context.pages[0] if context.pages else context.new_page()
            page.set_viewport_size({"width": WIDTH, "height": HEIGHT})
            page.goto(START_URL)
            mouse = {"x": WIDTH // 2, "y": HEIGHT // 2}

            messages = [{"role": "user", "content": f"The browser is open at {START_URL}. {TASK}"}]
            for _ in range(30):
                response = claude.beta.messages.create(
                    model=MODEL,
                    max_tokens=16000,
                    output_config={"effort": "medium"},
                    tools=TOOLS,
                    messages=messages,
                    betas=["server-side-fallback-2026-07-01"],
                    fallbacks="default",  # retry on a fallback model if this one declines
                )
                messages.append({"role": "assistant", "content": response.content})
                if response.stop_reason == "refusal":
                    print("Claude declined:", response.stop_details)
                    break

                results, failed = [], False
                for block in response.content:
                    if block.type != "tool_use":
                        continue
                    result = {"type": "tool_result", "tool_use_id": block.id}
                    if block.toolset_name == "computer":
                        result["toolset_name"] = "computer"
                        if failed:  # a batch stops at its first failed action
                            result.update(is_error=True, content="Not executed: an earlier computer action in this turn failed.")
                        else:
                            try:
                                result["content"] = run_action(page, block.name, block.input, mouse)
                            except Exception as e:
                                failed = True
                                result.update(is_error=True, content=f"Error: {e}")
                    elif block.name == "open_url":
                        page.goto(block.input["url"])
                        result["content"] = f"Opened {page.url}"
                    else:
                        result.update(is_error=True, content=f"Unknown tool {block.name}")
                    results.append(result)

                if not results:  # no tool calls: Claude is done
                    print("".join(b.text for b in response.content if b.type == "text"))
                    break
                messages.append({"role": "user", "content": results})
    finally:
        browsa.destroy_agent(cloud["id"])


if __name__ == "__main__":
    main()

Run it:

Terminal
python agent.py

fallbacks="default" with the server-side-fallback-2026-07-01 beta lets the API retry on a fallback model if Claude Opus 5.5 declines a request. Remove those two lines, and use claude.messages.create, if you don't want that.

How it works

  1. Match screenshots to the page. The script fixes the viewport at 1280x800 and takes screenshots in CSS pixels (scale="css"), so the coordinates Claude sends are the ones Playwright clicks.
  2. One tool call per action. With computer_toolset_20260801, each action arrives as its own tool_use block named for the action (left_click, type, screenshot...) with toolset_name: "computer", often several per turn. Every result echoes toolset_name; only screenshots and zooms send an image back.
  3. Add what a page screenshot lacks. Playwright screenshots show the page without an address bar, so a small open_url tool lets Claude navigate. destroy_agent() in finally shuts the browser down.

Troubleshooting

401 Unauthorized when connecting

The X-API-Key header didn't reach Browsa, or the key is wrong. Check that BROWSA_API_KEY is set in the shell that runs the script and that it starts with agt_; the Browsa SDK refuses keys that don't. Pass the header exactly as the script does.

The connection drops after a few minutes

The browser reached the end of its TTL and shut down. The scripts use ttl="5m" (1 credit). For longer runs, ask for more, up to "1h"; any TTL over 5 minutes costs 5 credits. Start a new browser for the next run.

400: does not support tool types: computer_20251124

Claude Opus 5.5 accepts computer use only as the {"type": "computer_toolset_20260801"} toolset, with no name or display size and no beta header. The script already uses it.

A tool result is rejected

Each result for a toolset call must include "toolset_name": "computer", and all results for a turn go back in one user message, as the loop does.

Requests get large on long tasks

Every screenshot stays in the conversation. Keep tasks short and the viewport at 1280x800. Don't delete old screenshots from messages: editing earlier turns invalidates Claude's earlier thinking blocks. Anthropic's docs cover server-side tool result clearing for long runs.

Next steps

Compare with text-based tools in the OpenAI Agents SDK guide, or let browser-use run the loop for you.

Run it on a cloud browser.

100 free credits. No card, no subscription.