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.
Summary
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.
Recipe
- Install Playwright for Python and browser binaries (verify versions at build).
- Create one browser context per run with a fixed viewport and optional storage state.
- Define tool schemas with narrow arguments (URL allowlist, CSS/role selectors, max text length).
- Implement tools that return short structured JSON plus optional screenshot image parts.
- Wire tools into your agent host (OpenAI-compatible tool calling or equivalent).
- Enforce max turns, navigation allowlist, and timeouts in the host - not only in prompts.
- Log each tool name, args, duration, URL, and stop reason for debugging.
- Add popup/login handling only after the happy path works on public pages.
- Close context and browser in
finallyso agents cannot leak processes.
Working Example
Conceptual sync Python host with a tiny tool set (verify Playwright APIs at build):
import base64
import json
from urllib.parse import urlparse
from playwright.sync_api import sync_playwright
ALLOWED_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()Deep Dive
Why tools beat "drive the whole CDP in one call"
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.
Locator strategy
| Strategy | Pros | Cons |
|---|---|---|
| CSS / test ids | Simple, fast | Brittle if classes churn |
Role + name (get_by_role) | Closer to user intent | Needs decent a11y |
| Text locators | Natural for agents | Ambiguous duplicates |
| Vision coordinates | Works without DOM | Cost, spatial error - see grounded actions page |
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.
Session and state
- One context per task isolates cookies and storage.
- Reuse storage state files only for deliberate logged-in runs in staging.
- Reset or close after each job so one agent cannot inherit another tenant's session.
Observation design
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.
Concurrency
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.
Gotchas
- No URL allowlist - the agent becomes an open proxy to the internal network.
networkidlewaits on SPAs - hangs forever; prefer load states tied to UI readiness.- Unbounded
read_text/ HTML - blows the context window and hides the signal. - Shared browser contexts across tenants - catastrophic session bleed.
- Click without re-observe - next action uses a stale mental model of the page.
- Headful assumptions in CI - use headless and fixed viewports for determinism.
- Missing process cleanup - zombie Chromium processes after agent crashes.
- Granting
evaluate/ arbitrary JS early - full XSS-as-a-service; keep it off production allowlists.
Alternatives
| Approach | Strength | Weakness | Best fit |
|---|---|---|---|
| Self-hosted Playwright tools (this page) | Full control, low vendor lock-in | You own browsers, proxies, scale | Internal apps, custom policy |
| Managed browser agent platforms | Scale, stealth, hosted runtimes | Cost, data residency, lock-in | High volume public web |
| Pure HTTP scrapers | Cheap and fast | No real UI interaction | Static or API-backed data |
| DOM-only (no vision) | Low cost | Fails on visual-only UIs | Well-instrumented apps |
| Coordinate-only vision agent | Works on remote desktops | Brittle and costly | Last resort UIs |
See Managed Browser Agent Platforms vs Self-Hosted Playwright for the buy-vs-build cheatsheet.
FAQs
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.
Related
- Multi-Modal & Browser Agents Basics - first screenshot loop
- Vision-Grounded Actions: Clicking What the Model Actually Sees - when selectors fail
- Handling Dynamic Pages, Popups, and Login Flows in Browser Agents - reliability recipe
- Managed Browser Agent Platforms vs Self-Hosted Playwright - platform choice
- Multi-Modal & Browser Agents Best Practices - safety checklist
- Custom Tools Basics - general tool design
Stack versions: Pins from the category manifest (verify at build): OpenRouter (~315+ models, July 2026 pricing/fees); LangGraph 1.0+; CrewAI 1.14+; Microsoft Agent Framework 1.0; Vercel AI SDK 6; Pydantic AI (latest); LlamaIndex (latest); OpenAI Agents SDK (latest + MCP); MCP (Linux Foundation governance); A2A (HTTP+SSE+JSON-RPC 2.0); Solana
@solana/web3.js+@solana/spl-token.