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
pip install anthropic playwright browsaThen put your keys in the environment:
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.
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:
python agent.pyfallbacks="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
- 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. - One tool call per action. With
computer_toolset_20260801, each action arrives as its owntool_useblock named for the action (left_click,type,screenshot...) withtoolset_name: "computer", often several per turn. Every result echoestoolset_name; only screenshots and zooms send an image back. - Add what a page screenshot lacks. Playwright screenshots show the page without an address bar, so a small
open_urltool lets Claude navigate.destroy_agent()infinallyshuts 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.