An AI browser agent built with the OpenAI Agents SDK and Playwright can read pages, follow links and extract data on its own, but only if you split the work correctly. This production-oriented Python tutorial walks through typed agent tools, Playwright browser control, structured extraction, routing traffic through ProxyEmpire proxies, sessions, approval gates, retries, prompt-injection defenses and scaling to hundreds of agents.
The short version
Let the model choose from a small, typed tool surface, and let your code do everything else. The Agents SDK provides the agent loop, tools, sessions, guardrails, human approval and tracing. Playwright provides deterministic browser execution. Your code enforces policy, the browser context owns web state, and the proxy session owns network identity.
User goal β OpenAI Agent β typed function tools β Playwright controller β browser context β ProxyEmpire proxy β website
Key takeaways
- Never expose the whole Playwright API to the model. Wrap it in a controller and expose a few narrow, typed tools.
- Prompt instructions are not a security boundary. Enforce allowed domains and actions in code.
- Return visible text or structured data, not raw HTML, and cap every tool output.
- Agent session, browser context and proxy session are three different things. Model them as three fields.
- Gate consequential actions such as submit, purchase or publish behind human approval.
- Give every job a finite budget of steps, time, tokens, retries and bandwidth.
Why Combine an Agent Runtime with Playwright?
Adaptive decisions, controlled executionA normal Playwright script is deterministic: you define navigation, locators, clicks and extraction in advance. An AI agent is adaptive: it can choose among allowed tools based on the user’s goal and the state returned by previous actions. Combining them gives you flexible decision-making without giving the model unrestricted browser or operating-system access.
| System | Strength | Weakness |
|---|---|---|
| Playwright script | Deterministic, testable browser control | Rigid when page structure or task sequence varies |
| LLM alone | Flexible reasoning | Cannot safely manipulate a real browser by itself |
| Agent + typed Playwright tools | Adaptive decisions with controlled execution | Needs explicit policy, state management and observability |
If headless browsers are new to you, our headless browsers primer and the explainer on why modern web automation runs on Chromium cover the background.
What the OpenAI Agents SDK Provides
The agent loop and its building blocksThe Python Agents SDK includes agents, function tools, agents-as-tools and handoffs, guardrails, sessions, human-in-the-loop approval flows and tracing. Function tools derive their schemas from Python types and docstrings, which makes them a natural boundary around a Playwright controller.
pip install openai-agents playwright
playwright install chromium
Set API credentials through the environment rather than in source code:
export OPENAI_API_KEY="..."
Python 3.10 or later. Current releases of openai-agents require Python 3.10+. The SDK evolves quickly, so pin the version you test against.
Step 1: Build a Small Browser Controller
The controller owns the browser, not the agentDo not expose the entire Playwright API to the model. Start with a controller that owns browser lifecycle, page state, network configuration and cleanup.
import os
from playwright.async_api import async_playwright
class BrowserController:
def __init__(self):
self._pw = None
self.browser = None
self.context = None
self.page = None
async def start(self):
self._pw = await async_playwright().start()
self.browser = await self._pw.chromium.launch(headless=True)
proxy = None
if os.getenv("PROXY_SERVER"):
proxy = {
"server": os.environ["PROXY_SERVER"],
"username": os.environ.get("PROXY_USER", ""),
"password": os.environ.get("PROXY_PASSWORD", ""),
}
self.context = await self.browser.new_context(proxy=proxy)
self.page = await self.context.new_page()
async def navigate(self, url: str) -> str:
await self.page.goto(url, wait_until="domcontentloaded")
return f"Opened {self.page.url}"
async def page_text(self, max_chars: int = 12000) -> str:
text = await self.page.locator("body").inner_text()
return text[:max_chars]
async def close(self):
if self.context:
await self.context.close()
if self.browser:
await self.browser.close()
if self._pw:
await self._pw.stop()
The controller, not the agent, should launch processes, hold Playwright object references, apply proxy settings and clean up contexts.
Step 2: Enforce Destinations in Code
Prompts are not a network security boundaryPrompt instructions are useful, but they are not a security boundary. Enforce allowed destinations before Playwright navigates:
from urllib.parse import urlparse
def assert_allowed_url(url: str, allowed_domains: set[str]):
host = (urlparse(url).hostname or "").lower()
if host not in allowed_domains:
raise ValueError(f"Domain not allowed: {host}")
A production validator should also account for redirects, subdomains, URL normalization, loopback and private-network addresses, and SSRF-style routing risks. The important point is that destination policy lives in code, not only in the agent prompt.
Step 3: Expose Typed Browser Actions as Agent Tools
Narrow, semantic, easy to validateThe Agents SDK provides the @tool decorator in agents.decorators, an alias for function_tool. Keep tools narrow, semantic and easy to validate:
from agents import Agent, Runner
from agents.decorators import tool
browser = BrowserController()
ALLOWED_DOMAINS = {"example.com", "www.example.com"}
@tool
async def open_url(url: str) -> str:
"""Open an allowed public URL in the browser."""
assert_allowed_url(url, ALLOWED_DOMAINS)
return await browser.navigate(url)
@tool
async def read_page(max_chars: int = 12000) -> str:
"""Return visible text from the current page."""
return await browser.page_text(max_chars=max_chars)
agent = Agent(
name="Web Research Agent",
instructions=(
"Use browser tools to research the user's request. "
"Do not submit forms, purchase anything, delete data, "
"publish content, or access private accounts."
),
tools=[open_url, read_page],
)
The SDK infers each tool’s schema from its signature and docstring. Critical restrictions should still be checked inside the tool or controller, so a poor model decision cannot bypass them.
Step 4: Run the Agent
The runner manages the tool loopimport asyncio
async def main():
await browser.start()
try:
result = await Runner.run(
agent,
"Open https://example.com and summarize what the page is for.",
)
print(result.final_output)
finally:
await browser.close()
asyncio.run(main())
The runner manages the agent/tool loop. When the model selects a browser tool, your Python function executes locally and returns a controlled result to the agent.
Do not return the entire DOM to the model
Raw HTML is often the wrong tool output. It can contain scripts, repeated navigation, hidden elements, tracking metadata and thousands of irrelevant tokens. Instead:
- Prefer visible text or structured extraction over raw HTML.
- Cap tool outputs and paginate when necessary.
- Expose focused tools such as
get_links(),get_headings()orextract_table(). - Use Playwright locators and accessibility semantics for controls.
- Store large artifacts outside the model context and return references or summaries.
Step 5: Add Controlled Interaction
Semantic inputs, resolved by your controllerAvoid tools that accept arbitrary JavaScript or unrestricted selectors from the model. Prefer semantic inputs that your controller resolves through Playwright:
@tool
async def click_text(text: str) -> str:
"""Click one visible link or button with the exact supplied text."""
locator = browser.page.get_by_text(text, exact=True).first
await locator.click()
await browser.page.wait_for_load_state("domcontentloaded")
return f"Clicked {text!r}. Current URL: {browser.page.url}"
For a real product, add action categories, explicit allowlists, preconditions and post-action verification.
Hard rule. A model-generated string should never become an unrestricted shell command, filesystem path, JavaScript program or privileged browser operation.
Step 6: Return Structured Data When the Task Has a Schema
Typed outputs are easier to trustfrom typing import TypedDict
class LinkItem(TypedDict):
text: str
href: str
@tool
async def list_links(limit: int = 30) -> list[LinkItem]:
"""Return visible links from the current page."""
links = browser.page.locator("a:visible")
count = min(await links.count(), limit)
output = []
for i in range(count):
item = links.nth(i)
output.append({
"text": (await item.inner_text()).strip(),
"href": await item.get_attribute("href") or "",
})
return output
Typed outputs reduce ambiguity and make browser tools easier to validate, log, test and pass to downstream systems.
Step 7: Route Browser Traffic Through ProxyEmpire
Network identity without exposing credentials to the modelPlaywright accepts a proxy per browser context. The controller above reads the proxy settings from environment variables, so ProxyEmpire can be added without the model ever seeing the credentials:
export PROXY_SERVER="http://v2.proxyempire.io:5000"
export PROXY_USER="YOUR_PROXY_USERNAME"
export PROXY_PASSWORD="YOUR_PROXY_PASSWORD"
Copy the username from the Connection Builder in the ProxyEmpire dashboard. It carries the targeting and session settings: for a sticky session, the dashboard adds a session ID (sid) to the username, as described in how to use proxy sticky sessions.
Use an HTTP proxy URL in Playwright. Playwright supports HTTP and SOCKS proxies, but it rejects username/password authentication on SOCKS5 proxies (“Browser does not support socks5 proxy authentication”). With ProxyEmpire’s username and password login, keep PROXY_SERVER on http://.
ProxyEmpire offers 30M+ rotating residential IPs with country, region, city and ISP targeting, plus new-IP-per-request, sticky-session and timed-rotation modes. Use the endpoint and username generated in the dashboard for the exact product and targeting profile you need.
Browser context owns cookies, local storage and tabs. Proxy session owns network exit identity. Agent session owns conversation and task memory. Treat them as three different fields in your job model.
Step 8: Use Agents SDK Sessions for Conversational Memory
Memory for the conversation, not the browserThe Agents SDK has built-in session memory. A session persists conversation history across Runner.run calls. It does not persist Playwright cookies or guarantee the same proxy IP.
from agents import SQLiteSession
session = SQLiteSession("browser_research_123", "agent_sessions.db")
first = await Runner.run(
agent,
"Open the allowed site and identify the main pricing plans.",
session=session,
)
second = await Runner.run(
agent,
"Now compare the two cheapest plans from the previous result.",
session=session,
)
For production, choose a session backend that matches your deployment. The SDK includes local SQLite options and additional backends for distributed applications. Keep browser persistence as a separate concern.
| State | Owned by | Holds |
|---|---|---|
| Agent session | Agents SDK session backend | Conversation and task memory |
| Browser state | Playwright browser context | Cookies, local storage and tabs |
| Network identity | Proxy session | The exit IP the website sees |
Step 9: Require Approval for Consequential Tools
Observing is not the same as actingThe Agents SDK can pause a run for human approval before a tool executes. Browser agents benefit from this, because observing a page is fundamentally different from creating an external side effect.
@tool(needs_approval=True)
async def submit_current_form() -> str:
"""Submit the current form after a human approves the action."""
await browser.page.locator("form").first.evaluate("form => form.requestSubmit()")
return "Form submitted."
In the application layer, handle the resulting interruption, show the pending action to the user, and resume only after approval. Keep read-only tools autonomous where appropriate, but gate actions such as submit, publish, purchase, delete, send or account changes.
Production: Jobs, Retries and Budgets
Policy as data, not prompt folkloreA production job model
from dataclasses import dataclass
@dataclass
class BrowserJob:
job_id: str
goal: str
allowed_domains: set[str]
proxy_profile: str
browser_profile: str
agent_session_id: str
max_steps: int = 30
deadline_seconds: int = 120
This turns policy into data instead of prompt folklore. Workers can enforce domain rules, step budgets, deadlines, browser isolation and network profiles even when the model makes a poor decision.
Retries: retry the layer that failed
| Failure | Likely response | Avoid |
|---|---|---|
| Transient navigation timeout | Retry navigation with bounded backoff | Restarting the whole agent immediately |
| Missing locator | Refresh page state and let the agent reconsider | Blind repeated clicking |
| Proxy connection failure | Retry or replace the network session | Assuming the model or target is at fault |
| 403 / 429 from destination | Respect target policy; slow down or stop as appropriate | Infinite IP rotation |
| Consequential action uncertain | Pause for approval | Guessing or auto-submitting |
Blindly restarting the entire workflow is expensive and can duplicate side effects. Classify failures so the retry happens at the browser, network, destination or agent layer that actually failed. Our reference on proxy error codes helps tell network failures from destination responses.
Step budgets prevent infinite agents
Every browser-agent job should have a finite budget. Otherwise a model can loop between pages, repeatedly inspect the same state, or keep searching after the marginal value has gone.
When a budget is exhausted, return an explicit partial result with the reason instead of an opaque crash.
Prompt Injection Is a Browser Security Problem
The agent reads adversarial content by designA browser agent consumes untrusted web content by design. A page can contain instructions that try to redirect the model, reveal secrets or trigger unrelated actions. A stronger prompt is not enough on its own:
- Treat page instructions as untrusted data.
- Keep tool permissions narrower than the model’s language capabilities.
- Never expose proxy or API credentials through browser-readable tool outputs.
- Validate destinations and redirects outside the model.
- Use low-privilege accounts for automation.
- Require approval for irreversible or externally consequential actions.
Scaling, Tracing and Testing
From one agent to hundredsScaling from one agent to hundreds
API / scheduler
|
v
durable job queue
|
+---+----------+
| |
v v
worker 1 ... worker N
| |
browser browser
context context
| |
+------+-------+
v
proxy layer
|
v
websites
Scale horizontally with workers, but keep browser concurrency bounded inside each worker. A useful capacity metric is successful jobs per unit of infrastructure and cost, not maximum simultaneous tabs.
- Use a durable queue and backpressure.
- Measure memory per active browser context.
- Keep per-tenant and per-destination concurrency limits.
- Map each job to an explicit browser profile and proxy profile.
- Recycle unhealthy or excessively long-lived browser processes.
For capacity planning at larger scale, see our guide to designing a browser cluster for 10,000 concurrent Chromium sessions.
Tracing and observability
The Agents SDK includes tracing for agent flows. Combine those traces with browser and proxy telemetry so you can follow one job end to end:
| Layer | Record |
|---|---|
| Job | job_id and the agent run/trace identifier |
| Tools | Tool name, argument classification and duration |
| Browser | Page URL/domain, navigation latency, browser context/profile identifier |
| Network | Proxy pool/session identifier (never the password) |
| Model | Model usage and cost |
| Outcome | Status, failure classification, final outcome and validation result |
Testing browser agents
- Unit-test browser tools without the model.
- Test policy enforcement with disallowed domains and actions.
- Run deterministic fixtures against stable demo pages.
- Evaluate whether the agent chooses the right tool sequence.
- Inject navigation timeouts and proxy failures.
- Test prompt-injection pages in a controlled environment.
- Measure success rate, steps, latency, model cost and proxy cost on a repeatable benchmark set.
When to Use Playwright MCP Instead
Local tools or a protocol serviceDirect Agents SDK function tools are a strong fit when one Python service owns the agent and the browser runtime. Playwright MCP is a stronger fit when browser automation should be a reusable protocol service for multiple AI clients, or when standardized MCP tool discovery matters. Those AI clients can also manage their proxies over MCP: the ProxyEmpire MCP server gives them proxy lists, coverage checks and bandwidth data from your account.
| Question | Agents SDK + local Playwright tools | Playwright MCP |
|---|---|---|
| One Python service owns everything | Strong fit | Possible, but adds a protocol boundary |
| Custom Python policy in each tool | Very direct | Requires MCP/server-side policy design |
| Many AI clients need the same browser tools | More integration work | Strong fit |
| Standard MCP tool surface required | No | Yes |
Production Checklist
Before you ship a browser agentChecklist
- Expose a narrow, typed browser tool surface.
- Keep browser lifecycle outside the model.
- Enforce domain and network policy in code.
- Use isolated browser contexts for unrelated jobs.
- Keep agent session, browser session and proxy session separate.
- Use ProxyEmpire sticky sessions intentionally, not as random retries.
- Require approval for consequential actions.
- Set step, time, token, retry and bandwidth budgets.
- Use bounded worker concurrency and durable queues.
- Trace agent decisions together with browser and network telemetry.
- Benchmark successful outcomes per dollar, not just raw throughput.
Final Thoughts
Deterministic tools, adaptive decisionsThe goal is not to make Playwright “intelligent.” Keep Playwright deterministic and give the agent a carefully designed set of actions.
The agent reasons; tools execute; browser contexts hold web state; proxy sessions provide network identity; policies constrain risk; queues constrain load; tracing explains what happened.
Once those responsibilities are explicit, the jump from a demo that opens a webpage to a production browser-agent platform becomes much more manageable.
Frequently Asked Questions
AI browser agents, in shortCan the OpenAI Agents SDK control a browser?
Not directly. It runs the agent loop and calls the tools you give it. Wrap Playwright in a controller and expose a few typed function tools, such as opening an allowed URL or reading visible page text; the SDK then decides which tool to call, and your Python code performs the browser action.
How do I use a proxy with a Playwright AI agent?
Pass a proxy to browser.new_context() with a server, username and password, and load those values from environment variables so the model never sees them. With ProxyEmpire, use http://v2.proxyempire.io:5000 and the username from the dashboard’s Connection Builder. Playwright does not support username/password authentication on SOCKS5 proxies, so use the HTTP URL.
Does an Agents SDK session keep the same browser cookies or IP?
No. A session persists conversation history across runs. Cookies, local storage and tabs live in the Playwright browser context, and the exit IP belongs to the proxy session. Manage the three separately.
How do I stop a browser agent from submitting forms or buying things?
Don’t give it a tool that can, or mark such tools with needs_approval=True so the run pauses for a human before the action executes. Enforce allowed domains and actions in code rather than relying on prompt instructions.
Should I use Playwright MCP or local Playwright tools?
Local function tools fit best when one Python service owns the agent and the browser and you want custom policy in each tool. Playwright MCP fits best when several AI clients need the same browser tools or you need a standard MCP tool surface.
Give your browser agents a reliable network identity
30M+ rotating residential IPs with country, region, city and ISP targeting, sticky sessions or a new IP per request, and per-context proxy settings that keep credentials out of the model.



