Building a Browser-Controlling Agent with Playwright
A browser-controlling agent is an LLM loop whose tools drive a real browser: open URLs, click, type, read text, and capture screenshots.
Playwright is a strong self-hosted substrate because it offers stable automation APIs, multi-browser support, and hooks for cookies, storage, and network control.
This page is a recipe for a minimal, least-privilege tool belt - not a full commercial browser-agent product.
Expose a small set of Playwright-backed tools (goto, click, type, read_text, screenshot), run them inside a max-turn agent loop, prefer DOM locators when possible, and treat every page observation as untrusted content.
Conceptual sync Python host with a tiny tool set (verify Playwright APIs at build):
import base64import jsonfrom urllib.parse import urlparsefrom playwright.sync_api import sync_playwrightALLOWED_HOSTS = {"example.com", "www.example.com"}class BrowserSession: def __init__(self): self._p = sync_playwright().start() self.browser = self._p.chromium.launch(headless=True) self.context = self.browser.new_context(viewport={"width": 1280, "height": 720}) self.page = self.context.new_page() self.page.set_default_timeout(15_000) def close(self): self.context.close() self.browser.close() self._p.stop() def goto(self, url: str) -> dict: host = urlparse(url).hostname or "" if host not in ALLOWED_HOSTS: return {"ok": False, "error": f"host not allowed: {host}"} self.page.goto(url, wait_until="domcontentloaded") return {"ok": True, "url": self.page.url, "title": self.page.title()} def click(self, selector: str) -> dict: self.page.locator(selector).first.click() return {"ok": True, "url": self.page.url} def type_text(self, selector: str, text: str) -> dict: self.page.locator(selector).first.fill(text) return {"ok": True} def read_text(self, selector: str = "body", limit: int = 4000) -> dict: text = self.page.locator(selector).inner_text()[:limit] return {"ok": True, "text": text, "url": self.page.url} def screenshot(self) -> dict: png = self.page.screenshot(type="png", full_page=False) b64 = base64.b64encode(png).decode("ascii") return { "ok": True, "url": self.page.url, "image_data_url": f"data:image/png;base64,{b64}", }TOOLS = [ {"type": "function", "function": { "name": "goto", "description": "Navigate to an allowlisted http(s) URL.", "parameters": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}, }}, {"type": "function", "function": { "name": "click", "description": "Click the first element matching a CSS selector.", "parameters": {"type": "object", "properties": {"selector": {"type": "string"}}, "required": ["selector"]}, }}, {"type": "function", "function": { "name": "type_text", "description": "Fill a field matching a CSS selector.", "parameters": { "type": "object", "properties": {"selector": {"type": "string"}, "text": {"type": "string"}}, "required": ["selector", "text"], }, }}, {"type": "function", "function": { "name": "read_text", "description": "Read visible text for a selector (truncated).", "parameters": {"type": "object", "properties": {"selector": {"type": "string"}, "limit": {"type": "integer"}}}, }}, {"type": "function", "function": { "name": "screenshot", "description": "Capture the current viewport as PNG for vision models.", "parameters": {"type": "object", "properties": {}}, }},]def run_tool(session: BrowserSession, name: str, args: dict) -> dict: if name == "goto": return session.goto(args["url"]) if name == "click": return session.click(args["selector"]) if name == "type_text": return session.type_text(args["selector"], args["text"]) if name == "read_text": return session.read_text(args.get("selector", "body"), args.get("limit", 4000)) if name == "screenshot": return session.screenshot() return {"ok": False, "error": f"unknown tool {name}"}
Agent loop sketch:
def run_browser_agent(client, model: str, goal: str, max_turns: int = 12): session = BrowserSession() messages = [ {"role": "system", "content": "Use browser tools to complete the goal. Prefer read_text; screenshot only when needed."}, {"role": "user", "content": goal}, ] try: for _ in range(max_turns): out = client.chat.completions.create( model=model, messages=messages, tools=TOOLS, tool_choice="auto" ) msg = out.choices[0].message messages.append(msg) if not msg.tool_calls: return msg.content for call in msg.tool_calls: args = json.loads(call.function.arguments or "{}") result = run_tool(session, call.function.name, args) # Host: if image_data_url present, attach as multi-modal tool result messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps({k: v for k, v in result.items() if k != "image_data_url"}), }) return {"status": "max_turns"} finally: session.close()
Models should not own raw Chrome DevTools Protocol sessions.
They should call narrow tools your host implements, so you can enforce allowlists, timeouts, and auditing.
Start with role/text locators in tool descriptions.
Add CSS when the app exposes stable data-testid attributes.
Escalate to vision grounding when the UI is canvas-like or selectors keep failing.
Return JSON like {ok, url, title, text} rather than raw HTML dumps.
Cap text length.
Use screenshots sparingly for vision models; they are expensive and sensitive.
Playwright pages are not free-threaded toys.
One page (or browser) per worker is the safe default.
Scale out with a pool of browsers or a managed browser service when load grows.
Should every browser tool be available every turn?
No. Stage tools if needed: read-only first, then click/type, then admin actions behind human approval.
CSS selectors or natural language targets?
Expose whatever your host can resolve reliably. Many teams accept selector plus optional role/name fields and resolve them in Playwright.
How do I keep the agent from wandering off-site?
Enforce host allowlists (and optionally path prefixes) inside goto and on every navigation event, not only in the system prompt.
Sync or async Playwright?
Async scales better under concurrent agents. Sync is fine for tutorials and single-worker jobs. Match your host runtime.
Where does the vision model fit?
Call screenshot when text observation is insufficient, then pass the image to a multi-modal model on the next turn. See the basics and vision-grounding pages.
How long should timeouts be?
Per-action timeouts in seconds (5-30s) plus a global wall-clock budget per run. Infinite waits are a production incident.
Can the agent handle file downloads?
Yes via Playwright download APIs wrapped as tools. Scan and size-limit files before any further processing.
What about iframes?
Frame-aware locators are required. Document frame selection in tool args or resolve frames in the host with explicit rules.
How do I test without the public internet?
Use static HTML fixtures served locally and recorded HAR/route mocks. Keep a small live smoke suite separate.
Is Playwright MCP or a browser MCP server better?
MCP can expose browser tools to any MCP client. The same least-privilege and allowlist rules still apply - packaging is not security.
How do I log runs without storing every screenshot?
Store tool JSON always; store screenshots on failure or sample N%. Encrypt and expire image artifacts aggressively.
When should I stop and use an API instead of the UI?
Whenever a stable API exists. Browser agents are for UIs you cannot replace, not for preferred integration paths.