The Best Open-Source Browser-Agent Frameworks in 2026
There are two completely different questions hiding inside "I want an AI agent that uses a browser," and most of the writing on the subject blurs them together. The first is where the browser runs: your laptop, a container, a hosted CDP endpoint, a VM. The second is what turns a language model's intentions into clicks: the loop that looks at a page, decides what to do, does it, and figures out whether it worked.
I have written a lot about the first question, because I build a compute platform and that is the part I sell. This post is about the second one. The framework layer. The library that sits between your prompt and Playwright and pretends, with varying degrees of success, that the model can see a web page.
It is a genuinely young category. Two years ago the answer was "write Playwright and give the model three tools." Today there are several projects with real users, real design opinions, and real failure modes, and the differences between them are not cosmetic. One serialises the DOM into text and asks the model to pick an element by index. One asks you to keep writing normal Playwright and only calls the model at the places where the page is unpredictable. One drives largely from screenshots and is aimed at people replacing an RPA vendor. One exposes the browser as MCP tools and lets whatever client you already have run the loop.
They are not the same product with different logos. Choosing wrong costs you either an enormous token bill, a test suite that is nondeterministic in a way nobody can debug, or a robot that confidently does the wrong thing on a live site and tells you it succeeded.
What a browser-agent framework actually does
Strip away the marketing and every one of these projects is solving the same four-step problem, over and over, until a task is done or a step budget runs out.
- Observe. Turn the current page into something a language model can reason about. This is the step that defines the whole framework, and it is where they disagree most.
- Decide. Give the model the observation plus the task plus some history, and get back an intended action: click this, type that, scroll, go back, extract these fields, we are finished.
- Act. Translate the intent into a real browser operation. Usually Playwright or raw CDP underneath, occasionally a synthetic mouse event at pixel coordinates.
- Verify. Look at the page again and decide whether the action did what the model thought it would. Most frameworks do this implicitly by feeding the next observation back into the loop, which is not the same thing as verification, and the difference shows up in your incident channel.
That loop runs anywhere from three to fifty times for a real task. Every iteration is at least one model call carrying a fresh observation. Hold that thought, because it is the single biggest cost driver in the entire category and almost nobody leads with it.
DOM serialisation versus vision grounding
The observation step splits the field. There are two honest strategies and a lot of hybrids.
DOM serialisation walks the rendered page — usually the accessibility tree rather than raw HTML, because raw HTML is mostly noise — and produces a numbered list of things you can interact with. Element 14 is a button labelled Checkout. Element 15 is a text input with placeholder Email. The model responds with something like click(14), and the framework maps index 14 back to a real element handle and clicks it. The mapping is the whole trick: the model never sees a CSS selector, it sees a menu.
This is fast, it is cheap compared to images, and it is precise — when the framework picks the right element, it clicks exactly that element, not a pixel near it. The failure modes are specific. Canvas-rendered UIs are invisible to it. So is anything drawn in WebGL, most PDF viewers, and a surprising amount of what a design system calls a "custom select." Shadow DOM and iframes need explicit handling and the quality of that handling varies a lot. And on a dense page — a data grid, a marketplace listing page, an enterprise admin console — the serialised element list is enormous.
Vision grounding takes a screenshot instead. The model looks at the picture, decides where to click, and returns coordinates or a description that gets resolved to coordinates. This handles canvas, WebGL, weird custom widgets and visually-obvious-but-structurally-invisible affordances, because it is looking at the same thing a human would look at. It also handles the case where the DOM says a button exists and it is actually behind a modal overlay.
The costs are the mirror image. Images are expensive tokens. Coordinate precision is a real problem: models are much better at "that button is roughly there" than at "that button's centre is at (743, 219)," which is why serious vision-based systems add a grounding layer — draw numbered boxes over the candidate elements, or run a separate model whose only job is turning a description into coordinates. And a screenshot loses everything below the fold unless you scroll, which costs more steps, which costs more tokens.
Most frameworks now do both: DOM for structure, a screenshot for disambiguation, sometimes with the interactive elements outlined and numbered on the image so the two representations line up. That hybrid is the current default and it is a reasonable one. It is also the most expensive per step, which nobody puts on the landing page.
Who owns the loop is the other axis
The second thing to look at, and the one that decides how the framework fits your codebase, is whether the library runs the loop or you do.
Loop-owning frameworks take a task string — "find the cheapest flight to Lisbon next Tuesday and screenshot the price" — and run until they think they are finished. You get a result object and a trace. This is wonderful for exploration and demos and genuinely useful for tasks where you cannot enumerate the steps in advance. It is uncomfortable in production because the control flow lives inside someone else's library, and when it goes wrong your options are to read a trace and adjust a prompt.
Composable frameworks give you primitives you sprinkle into code you already control. You write the Playwright script; at the three places where the page is unpredictable you call an AI-backed step instead of a selector. Your for-loops, your retries, your error handling, your logging. This is much less impressive in a demo video and much easier to run on a Tuesday afternoon in December.
A useful heuristic: if you can write down the steps, you want composable. If you genuinely cannot — because the site changes, or because a human gave you the goal in English and you do not know what the site looks like yet — you want a loop.
The framework question is really a control-flow question. Are you delegating a goal, or are you delegating three lookups inside a script you still own?
The five options worth evaluating in 2026
I have deliberately not ranked these with scores, because the honest ranking depends entirely on which of the two axes above matters to you. Here is what each one is, what it optimises for, and where I would not use it. Check each project's docs for current capabilities — everything below is about design shape, which changes slowly, not features, which change weekly.
browser-use
The best-known open-source loop-owner. Python, Playwright underneath, and the design is squarely in the DOM-serialisation camp with vision available as a supplement. You hand it a task in English and a model, and it drives.
What it gets right is the observation format. The numbered-interactive-element representation it popularised is a genuinely good abstraction: it is compact relative to raw HTML, it maps cleanly back to real element handles so clicks are precise rather than positional, and it gives the model a closed menu of options instead of an open-ended "write me a selector" prompt, which cuts a whole class of hallucination. The project also does the unglamorous work — waiting for navigation, handling new tabs, dealing with the fact that clicking a thing sometimes replaces the entire page — that you would otherwise write yourself and get wrong.
Where it hurts: it owns the loop, and the loop is the expensive part. A dense page produces a large element list, and you send a version of that list on every single step. Long tasks accumulate history. If you are running one task interactively that is fine; if you are running ten thousand a day against the same three pages, you are paying a model to rediscover the same page structure ten thousand times. There are caching and reuse strategies for this and they are worth investigating, but the shape of the cost is inherent to the design.
I would use it for tasks where the page is genuinely unknown, for prototyping a flow before hard-coding it, and for the long tail of "a customer asked for an integration with a site nobody has heard of." I would not use it as the execution engine for a high-volume, well-known workflow.
Stagehand
Browserbase's framework, and the clearest expression of the composable philosophy. The pitch is that you keep writing Playwright — your page objects, your assertions, your waits — and reach for AI only at the steps where a selector would be brittle. The primitives are small and orthogonal: act on a natural-language instruction, extract structured data against a schema, observe to get back candidate actions before committing to one.
The observe-then-act split is the part I like most, and it is underrated. Getting back a list of proposed actions and choosing one yourself — or caching it, or logging it, or refusing it — is exactly the seam you need to make an AI step auditable. It converts "the model did something" into "the model proposed this, we did it," which is the difference between a debuggable system and a mystery.
The caching story matters too. Once an AI-resolved step has produced a concrete action, you can reuse it on subsequent runs and skip the model call entirely, falling back to the model only when the cached action stops working. That is the right shape for a production workflow: deterministic and free in the common case, self-healing in the uncommon one.
Where it hurts: it is a library, not an autopilot. If you were hoping to hand it a sentence and go and make coffee, that is a different product. And it comes from a company that also sells browser infrastructure, so the happy path is well integrated with their hosted browsers; running it entirely on your own infrastructure is supported but you should verify how much of the ergonomics you keep. Also worth checking which languages are first-class right now — this project has been moving.
Skyvern
Skyvern comes at the problem from the RPA direction rather than the developer-tooling direction, and it shows in every design decision. It leans heavily on vision, it thinks in terms of workflows rather than scripts, and the target user is someone who was previously paying a licence fee to have a robot fill in a supplier portal.
The vision-first choice is not fashion. If your job is government forms, insurance portals, procurement systems and bank interfaces, you are dealing with pages that are structurally hostile: framesets, table layouts pretending to be forms, custom widgets that predate the accessibility tree being taken seriously, and PDF-ish things embedded mid-flow. Screenshots degrade far more gracefully there than DOM serialisation does. The tradeoff is the usual one — more expensive per step, less precise clicking, more reliance on a grounding layer.
It is also the one in this list most likely to come with a UI and a workflow concept rather than being purely a library, which is right for its audience and a mismatch if you want a function you call from a queue worker. It is open source and self-hostable, which is the reason it belongs in this comparison at all rather than in the vendor bucket.
I would look at Skyvern seriously if the phrase "we have forty supplier portals and each one is different" describes your problem. I would not reach for it to scrape a well-structured e-commerce site.
Playwright MCP and the MCP-server approach
Different shape entirely: instead of a library your code imports, you run a server that exposes browser operations as tools, and whatever MCP client you already have — an IDE assistant, a chat interface, your own agent runtime — calls them. The Playwright MCP server from the Playwright team is the reference implementation, and it observes via the accessibility tree rather than pixels, which puts it in the DOM-serialisation camp.
The appeal is that you do not adopt a framework at all. If you already have an agent that does tool-calling, you have already solved the loop, the history management, the retry policy and the observability. Adding a browser is adding a tool server. Your existing model, your existing budget controls, your existing traces.
This is my default recommendation for anyone whose agent already exists. It is also, right now, the most portable choice: MCP servers are trivially swappable, and if you decide the browser tool is wrong you replace one process rather than refactoring an agent.
Where it hurts: the tool interface is generic, so you get generic behaviour. A framework that owns the loop can do clever things — retrying a click with a different strategy, re-observing after a navigation, batching a fill-and-submit — because it knows what it is doing next. A tool server answers one call at a time and has no idea what your model is planning. You will find yourself burning steps on things a purpose-built loop would have handled in one. And running an MCP server that drives a browser raises a deployment question — one server per concurrent session, or one server multiplexing sessions? — that the protocol does not answer for you.
The baseline: plain Playwright behind typed tools
Write the automation yourself. Expose a handful of narrow, typed functions to the model — search_products(query), add_to_cart(product_id), get_order_status(order_id) — and let the model choose between them. The model does planning and language; your code does the browsing.
This is not a fallback. For most production browser automation it is the correct architecture, and the fact that it is unfashionable does not make it wrong. It is deterministic. It is debuggable with a stack trace. It costs one model call per decision instead of one per DOM observation. It fails loudly, in a way your existing alerting already understands. And when it breaks, it breaks because a selector changed, which is a five-minute fix by a human who can read the diff — rather than because a model's interpretation of a page drifted, which is a fix nobody can estimate.
The genuine limitation is that it only works when you know what the page looks like. The moment the target is unknown, or changes weekly, or there are two hundred variants of it, hand-written selectors become a maintenance treadmill and the frameworks start earning their keep.
The five, side by side
A rough map, not a scorecard. Individual projects move fast and the vision-versus-DOM lines in particular are blurring as everyone adds hybrid modes. Verify anything load-bearing against the project's own documentation.
- Who owns the loop — browser-use: the framework. Stagehand: you do, with AI-backed steps. Skyvern: the workflow engine. Playwright MCP: your existing agent. Plain Playwright: you, entirely.
- Primary observation — browser-use: serialised interactive elements, vision optional. Stagehand: DOM-oriented with model-resolved actions. Skyvern: vision-first with grounding. Playwright MCP: accessibility tree. Plain Playwright: none, you wrote the selectors.
- Determinism — browser-use: low, by design. Stagehand: medium, high once actions are cached. Skyvern: low to medium. Playwright MCP: depends entirely on your agent. Plain Playwright: total.
- Token cost per step — browser-use: moderate to high on dense pages. Stagehand: low when steps are cached, moderate otherwise. Skyvern: highest, images are not cheap. Playwright MCP: moderate. Plain Playwright: near zero for the browsing itself.
- Handles an unknown page — browser-use: very well. Stagehand: well at the step level. Skyvern: best on structurally hostile pages. Playwright MCP: reasonably. Plain Playwright: not at all.
- Fits an existing codebase — browser-use: it wants to be the entry point. Stagehand: drops into a Playwright script. Skyvern: a service you call. Playwright MCP: a tool server you register. Plain Playwright: it is your codebase.
- Debuggability when it fails — browser-use: read the trace, tune the prompt. Stagehand: normal stack traces plus a model-step trace. Skyvern: run recordings and screenshots. Playwright MCP: your agent's existing traces. Plain Playwright: a stack trace pointing at a line number.
- Best fit — browser-use: unknown or long-tail sites, prototyping. Stagehand: production flows with a few brittle steps. Skyvern: RPA replacement on portals and forms. Playwright MCP: teams that already have an agent. Plain Playwright: known, stable, high-volume targets.
How I would actually choose
- Do you already have an agent runtime with tool-calling? Start with an MCP browser server. You will know within a week whether you need more.
- Can you write the selectors, and will they still be valid next month? Write plain Playwright. Expose it as typed tools. Stop reading roundups.
- Is the script mostly stable but with two or three steps that keep breaking? Composable is your answer — keep the script, make those steps AI-resolved, cache the resolution.
- Is the target genuinely unknown at write time — a user pastes a URL, or you support hundreds of sites? A loop-owning framework earns its cost here. Budget for the token bill up front.
- Are the targets old, structurally awful, form-heavy portals? Look hard at the vision-first option, and be honest that per-step cost will be your dominant line item.
- Whatever you pick, prototype with it against your five worst real pages, not against a demo site. Every framework in this category looks brilliant on a clean page.
The five things that bite, which most roundups skip
Everything above is the part you can decide from documentation. What follows is the part you find out in week three.
1. You pay for the page on every single step
This is the cost model nobody explains up front. A DOM-serialising framework builds a representation of the interactive page and puts it in the prompt. Then the model acts. Then the page changed, so it builds a new representation and puts that in the prompt. A ten-step task sends ten page representations.
On a simple page that is fine. On a real one — a product listing with a hundred cards, each card containing a link, a wishlist button, a variant selector and a quick-add — the element list is long, and you are sending it repeatedly. Add a screenshot per step for vision grounding and the per-step cost roughly doubles or worse depending on resolution.
Measure this before you commit. It is a ten-minute experiment and it will change your architecture:
import json, time
from collections import Counter
# Run ONE representative task with whatever framework you are evaluating,
# with the model client wrapped so every call is logged. The point is not
# a precise bill -- it is finding out whether a task costs cents or dollars,
# because those are different products.
calls = [] # append {"step": n, "prompt_tokens": ..., "completion_tokens": ...}
def report(calls):
steps = len(calls)
pin = sum(c["prompt_tokens"] for c in calls)
pout = sum(c["completion_tokens"] for c in calls)
print(f"steps : {steps}")
print(f"prompt tokens total : {pin:,}")
print(f"prompt tokens / step : {pin // max(steps, 1):,}")
print(f"output tokens total : {pout:,}")
# The number that actually matters: if you ran this 10k times a day.
print(f"prompt tokens @ 10k/day: {pin * 10_000:,}")
# Then run the SAME task as hand-written Playwright with typed tools and
# compare. If the framework costs 40x and saves you a day of selector
# writing, that trade is fine for 100 runs a day and insane for 100k.Two mitigations actually work. The first is caching resolved actions: once the model has told you that the checkout button is the third button inside the order summary, store that and use it directly next time, only falling back to the model when the stored action misses. The second is narrowing the observation: most frameworks let you scope observation to a container, and scoping to the form you care about instead of the whole page can cut the representation dramatically. Both of these push you back toward the composable end of the spectrum, which is not a coincidence.
2. The same script passes and fails
Traditional flaky tests are flaky because of timing. Agent flakiness is different in kind: the model made a different decision. Same page, same prompt, different route through the flow. One run clicks the cookie banner and proceeds; the next decides the banner is irrelevant and clicks a button underneath it that is not actually clickable yet.
This breaks the mental model everyone brings from CI. A test that fails 3% of the time gets marked flaky and retried. An agent that takes a different-but-valid path 30% of the time is not flaky, it is working as designed, and "retry it" is not a strategy when each retry costs real money and each path has different side effects.
The things that help, in the order I would apply them:
- Pin what you can. Temperature down, seed fixed if your provider supports it, and accept that neither gives you determinism — they narrow the distribution, they do not collapse it.
- Assert on outcomes, not paths. Do not check that the agent clicked three things; check that the order exists in the database afterwards. The path is the framework's business, the outcome is yours.
- Cap the step budget hard, and treat hitting the cap as a failure that pages someone, not as a timeout to retry. An agent that needed forty steps for a five-step task did something you want to look at.
- Record every run. Screenshot per step, the observation sent, the action returned. When something goes wrong you need the trace, and reconstructing it after the fact is impossible.
- Make the whole run idempotent where you can, keyed on something stable, so a retry after a partial failure does not double-submit. This is ordinary distributed-systems hygiene and agents make it mandatory.
3. Where the credentials live when the agent logs in as you
This is the part that should keep you up at night, and it gets one line in most framework READMEs.
A browser agent that logs into an account is operating with that account's full authority. Not a scoped API token with three permissions — the whole session. Whatever a human could do in that browser, the agent can do, and so can anything that manages to influence the agent.
Three failure shapes, all of which I have seen or heard about first-hand:
- The password goes into the prompt. Someone writes a task string like "log in with user X and password Y, then download the invoices." That password is now in your model provider's logs, your own request logs, your traces, and whatever observability vendor you route through. Never put a credential in a prompt. Type it with code the model cannot see.
- The session outlives the task. An agent logs in, does its job, and the profile directory with the session cookie sits on a shared disk until someone cleans it up. Now the credential is a file, and its blast radius is whoever can read that file.
- Prompt injection reaches an authenticated session. This is the one that is genuinely new. A page can contain text — hidden in a div, in alt text, in a product review, in a PDF the agent opens — that reads to the model as an instruction. If your agent is logged into something with authority and it reads a page that says to go and do something else, there is no filter that reliably stops it. Anyone selling you one is selling you a probability.
The defence is architectural, not textual. Assume the agent will eventually be persuaded, and make the persuaded agent's maximum damage small. Scoped credentials rather than a full-authority session. Default-deny egress, so the exfiltration URL the injected text asked for does not resolve. A human confirmation step on anything irreversible. And a session environment that is destroyed afterwards rather than reused.
# The credential pattern that does not leak: the task string describes the
# goal, and the secret is typed by code the model never sees.
TASK = """Log in to the supplier portal using the credentials that have
already been entered for you, then download every invoice issued this
month to /work/invoices/."""
def login_out_of_band(page, username_env: str, password_env: str) -> None:
"""Runs BEFORE the agent loop starts. The model gets a logged-in page,
never the secret, and nothing sensitive enters a prompt or a trace."""
page.goto(PORTAL_URL, wait_until="domcontentloaded")
page.fill("#username", os.environ[username_env])
page.fill("#password", os.environ[password_env])
page.click("button[type=submit]")
page.wait_for_url(re.compile(r"/dashboard"), timeout=30_000)
# Better still: do the login once, snapshot the machine, and fork the
# snapshot per task -- see the fork section below. Then the password is
# typed once, in one place, and never again.4. Concurrency is memory, and browsers are not small
One agent, one browser. That is the shape, and it does not amortise. Chromium's resident memory for a real session with a handful of tabs open is measured in hundreds of megabytes and climbs happily past a gigabyte on heavy sites. Multiply by concurrency.
The tempting optimisation is to share: one Chromium process, one browser context per agent. Contexts are genuinely isolated from each other for cookies and storage — that is what they are for — and the memory saving is real. But you have put every concurrent agent in one process. One page that pins the CPU degrades everyone. One renderer crash can take out neighbours. And if a page ever manages a renderer exploit plus a sandbox escape, it is facing the same kernel as every other session in the pool.
The other thing that breaks at concurrency is state. Agents leave things behind: downloads, a service worker cache, a stray dialog, a page in a state your next task did not expect. Recycling contexts saves startup time and imports the previous run's mess. If you are recycling, you need an aggressive reset, and "we clear cookies between runs" is not a reset.
Do the arithmetic before you choose an architecture, because it usually decides for you:
# Rough per-session memory on a real target, measured inside one session's
# environment rather than guessed. Run the agent, then look.
# Resident set of the whole browser process tree:
ps -o rss= -C chromium --ppid $(pgrep -f 'chromium.*--remote-debugging') 2>/dev/null \
| awk '{s+=$1} END {printf "chromium RSS: %.0f MiB\n", s/1024}'
# Chromium's own accounting, which is more honest about shared memory:
chromium --headless --dump-dom about:blank >/dev/null 2>&1
# Then the only number that matters:
# concurrent_agents * per_session_MiB = the host you need to buy
# 50 concurrent sessions at 700 MiB is 35 GiB of RAM before you have run
# a single model call. Plan for peak, not for average.5. The real failure mode is being confidently wrong
Normal automation fails by crashing. A selector does not match, Playwright throws, your alerting fires, someone looks at a stack trace. The failure is loud, localised, and self-describing.
A browser agent's characteristic failure is the opposite. It does something. Just not the thing you wanted. It filters by the wrong date range and reports a number. It cancels the wrong subscription because two rows looked alike. It fills a form with plausible values for a field it did not understand. It hits a paywall, reads the teaser, and summarises the article it could not see. And then it returns success, because from inside the loop the task looked complete.
There is no framework-level fix for this, and the honest position is that you have to design around it. What works:
- Verification that is independent of the agent. If the agent says it placed the order, check the order via an API, a database query, a confirmation email — anything the agent did not produce. Self-reported success is not evidence.
- A confirmation gate on anything irreversible. Money, deletion, sending a message, changing a permission. The agent proposes, a human or a stricter rule disposes. This is annoying and it is the difference between a mistake and an incident.
- Narrow the tools. Every capability the agent has is a way to be wrong. If the task is reading invoices, it does not need a tool that can submit a form.
- Bound the damage per run. A step budget, a spend limit, a rate limit on any action with a side effect. Assume some percentage of runs go wrong and decide in advance what that costs.
- Log the observation, not just the action. When you are working out why it did that, you need to see what it saw. The action alone tells you nothing.
Traditional automation fails by stopping. Agentic automation fails by continuing. Build your alerting for the second one, because your existing alerting only catches the first.
The layer under the framework, which is where I live
Every framework above assumes a browser exists somewhere. Where that somewhere is turns out to matter more than the framework choice for anything multi-tenant, anything authenticated, and anything running at volume.
The framework is the agent loop. The substrate is the isolation boundary, the cookie jar, the network policy and the teardown guarantee. You choose those independently, and the second choice is the one with the security consequences.
One microVM per agent session
On PandaStack each browser agent gets a Firecracker microVM: its own guest kernel under KVM, its own copy-on-write disk, its own network namespace with a dedicated tap device. Two agents share the hypervisor and nothing else. No shared kernel, no shared page cache, no shared profile directory, no shared Chromium process.
Concretely, three properties fall out of that which matter specifically for browser agents:
- The cookie jar is per sandbox. A logged-in session belongs to one VM. There is no filesystem path where another tenant's session cookie could be read, because there is no shared filesystem.
- A compromised page is contained by hardware virtualization. Chromium's own renderer sandbox already assumes the renderer will be compromised. Chained with a sandbox escape, a container leaves attacker code facing the host kernel that every other session shares. In a microVM it leaves them root in a disposable VM whose kernel nobody else uses; reaching a neighbour needs a hypervisor break.
- Egress is per session. The netns means iptables rules govern exactly one browser and disappear with it. Default-deny plus an allowlist is a per-task decision, not a global firewall change — which is what makes prompt-injection exfiltration a non-event rather than an incident.
The objection to a VM per session has always been that it is too slow and too expensive. Snapshot-restore is the answer to the first half: every create restores a baked template snapshot rather than booting, at a p50 of about 179 milliseconds. A first-ever cold boot is around three seconds; after that it is snapshot restores. Scale-to-zero handles the second half — a session that is not running costs nothing, so you are not paying for a warm pool of idle browsers.
Snapshot a logged-in browser, then fork it
This is the pattern I would reach for first, and it is the one that most directly attacks the credential problem from the previous section.
Log in once, by hand or with a script, in a persistent sandbox. Snapshot the whole machine — memory and disk, so the running Chromium with its live session is captured, not just a cookie file. Then fork that snapshot per task. Each fork is a copy-on-write clone: same-host forks land in 400 to 750 milliseconds, cross-host in roughly 1.2 to 3.5 seconds.
What you get out of it:
- The password is typed once, ever. Every subsequent run starts authenticated without touching a credential store, without a login form, without a secret entering a prompt.
- No login flow to break. Login pages are the most-changed, most-defended, most-CAPTCHA'd pages on any site. Replaying them per run is the single most fragile thing a browser agent does. Doing it zero times per run is strictly better.
- Parallel runs are trivially isolated. Ten forks of one logged-in state are ten independent VMs, each with its own copy of the session. One of them getting logged out, rate-limited or hijacked does not touch the others.
- The pristine original is never exposed. Hostile pages only ever touch a fork, and the fork is destroyed at the end of the task. The base snapshot is not something a page can reach.
Running a browser-agent framework inside a sandbox
Here is the whole thing end to end: create a microVM on the browser template, install the framework, hand it a task, and read the result back. Nothing here is framework-specific in a way that matters — swap the two lines that install and invoke the agent and the shape is identical for any of the options above.
import json
import os
import shlex
from pandastack import Sandbox
# The agent script that runs INSIDE the microVM. It never sees a
# credential, and the only thing it returns is JSON on stdout.
AGENT_SCRIPT = r'''
import asyncio, json, os, sys
from browser_use import Agent, Browser
from browser_use.llm import ChatAnthropic
async def main() -> None:
task = sys.argv[1]
# Headless Chromium, in this VM, with nothing else on the machine.
browser = Browser(headless=True)
agent = Agent(
task=task,
llm=ChatAnthropic(model="claude-sonnet-4-5"),
browser=browser,
# Hard step budget. Hitting it is a failure you want to see,
# not a timeout you silently retry.
max_steps=25,
)
history = await agent.run()
# Report the OUTCOME and the trace separately. Self-reported success
# is not evidence -- the caller verifies independently.
print(json.dumps({
"result": history.final_result(),
"steps": len(history.history),
"urls": history.urls(),
"errors": [str(e) for e in history.errors() if e],
}))
await browser.close()
asyncio.run(main())
'''
def run_browser_agent(task: str, allowed_hosts: list[str]) -> dict:
"""Run one browser-agent task in a disposable microVM."""
# ttl_seconds is the dead-man's switch. If this process dies mid-task
# the VM reaps itself -- no orphaned Chromium, no surprise bill.
with Sandbox.create(
template="browser",
ttl_seconds=900,
metadata={"kind": "browser-agent"},
) as sbx:
# 1. Lock down egress BEFORE the browser can reach the network.
# An injected "send this to evil.example" only works if DNS
# and TCP cooperate. Here they do not.
lock = sbx.exec(
"bash /opt/session/egress.sh " + " ".join(allowed_hosts),
timeout_seconds=60,
)
assert lock.exit_code == 0, lock.stderr
# 2. Install the framework and drop the script in.
sbx.exec("pip install --quiet browser-use", timeout_seconds=300, check=True)
sbx.filesystem.write("/opt/session/agent.py", AGENT_SCRIPT)
# 3. Run it. The API key is passed as an environment variable in
# the command, never baked into the image and never in a prompt.
res = sbx.exec(
"ANTHROPIC_API_KEY=" + shlex.quote(os.environ["ANTHROPIC_API_KEY"]) +
" python3 /opt/session/agent.py " + shlex.quote(task),
timeout_seconds=600,
)
if res.exit_code != 0:
raise RuntimeError(res.stderr[-4000:])
out = json.loads(res.stdout.strip().splitlines()[-1])
# 4. Pull the artifacts out before the VM dies.
try:
out["screenshot"] = sbx.filesystem.read("/tmp/final.png")
except Exception:
pass
return out
# VM destroyed on block exit. Cookies, cache, downloads, and anything
# a hostile page wrote to disk go with it.
if __name__ == "__main__":
result = run_browser_agent(
task="Open the docs site, find the pricing page, and report the "
"name of every plan listed. Do not click anything that "
"submits a form.",
allowed_hosts=["docs.example.com", "www.example.com"],
)
print(json.dumps(result, indent=2))Four things in there are doing real work and are worth calling out, because they are the parts people skip.
- Egress is locked down before the browser starts, not after. Ordering matters: a rule applied after the first page load is a rule that did not apply to the first page load.
- The step budget is hard, and hitting it is an error. An agent that wanders for forty steps has found something you need to look at.
- The API key arrives as an environment variable on the command, not baked into a template image where it would end up in every snapshot of that template forever.
- The context manager is the teardown. There is no cleanup script whose correctness matters, because the security boundary is the VM ceasing to exist, not a directory being wiped.
The egress script, which runs in the guest
This is the file the example calls. It runs inside one session's VM, in that VM's own network namespace, so the rules govern exactly one browser and vanish when the VM does.
#!/usr/bin/env bash
# /opt/session/egress.sh -- runs INSIDE one agent session's microVM.
# Usage: egress.sh host1 host2 ...
set -euo pipefail
RESOLVER=1.1.1.1
# Default-deny outbound. A browser agent with open egress is a very
# well-isolated pivot host, which is not the goal.
iptables -P OUTPUT DROP
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -p udp -d "$RESOLVER" --dport 53 -j ACCEPT
# No private ranges, and emphatically not the cloud metadata endpoint --
# the most popular destination in every SSRF write-up ever published.
for cidr in 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16; do
iptables -A OUTPUT -d "$cidr" -j REJECT
done
# Allowlist: HTTPS to the resolved addresses of the named hosts only.
for host in "$@"; do
for ip in $(getent ahostsv4 "$host" | awk '{print $1}' | sort -u); do
iptables -A OUTPUT -p tcp -d "$ip" --dport 443 -j ACCEPT
done
done
# Log what gets dropped. When an agent misbehaves, this is the first
# place you look -- it tells you where it TRIED to go.
iptables -A OUTPUT -j LOG --log-prefix "egress-denied: " -m limit --limit 5/min
echo "egress: default-deny, $# host(s) allowed"One caveat worth knowing: allowlisting resolved addresses is a snapshot in time. A host behind a CDN can rotate addresses mid-session and your allowlist goes stale. For long sessions, either re-resolve periodically, allowlist by CIDR where the provider publishes ranges, or put an explicit HTTP proxy in the path and filter by hostname there. Pick your poison deliberately rather than discovering it at 2am.
The fork-a-logged-in-session pattern, in full
Two phases. Phase one runs occasionally — a cron job, or whenever the session expires — and produces a snapshot of a logged-in browser. Phase two runs per task and forks it.
import os
import time
from pandastack import Sandbox
# ---------------------------------------------------------------
# Phase 1: log in ONCE and snapshot the machine. Run this on a
# schedule (sessions expire) rather than per task.
# ---------------------------------------------------------------
LOGIN_SCRIPT = r'''
import os, sys
from playwright.sync_api import sync_playwright
PORTAL = os.environ["PORTAL_URL"]
with sync_playwright() as p:
# Persistent context: the profile lives on disk in this VM, so the
# snapshot captures the logged-in state including the running browser.
ctx = p.chromium.launch_persistent_context(
user_data_dir="/opt/profile",
headless=True,
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(PORTAL, wait_until="domcontentloaded")
# Credentials are typed by code. They are never in a task string,
# never in a prompt, never in a model provider's request log.
page.fill("#username", os.environ["PORTAL_USER"])
page.fill("#password", os.environ["PORTAL_PASS"])
page.click("button[type=submit]")
page.wait_for_selector("[data-testid=dashboard]", timeout=30_000)
# Leave the browser RUNNING. The snapshot captures guest memory too,
# so forks wake up with this exact browser, on this exact page.
print("logged in:", page.url)
sys.stdout.flush()
while True:
page.wait_for_timeout(60_000)
'''
def bake_logged_in_session() -> tuple[str, str]:
"""Log in once, snapshot the machine, return (sandbox_id, snapshot_id)."""
warm = Sandbox.create(
template="browser",
persistent=True, # exempt from the idle reaper
metadata={"role": "warm-login", "portal": "supplier"},
)
warm.filesystem.write("/opt/session/login.py", LOGIN_SCRIPT)
env = (
"PORTAL_URL=" + os.environ["PORTAL_URL"] +
" PORTAL_USER=" + os.environ["PORTAL_USER"] +
" PORTAL_PASS=" + os.environ["PORTAL_PASS"]
)
# Detached: the browser must still be running when we snapshot.
warm.exec(
"setsid env " + env +
" python3 /opt/session/login.py > /var/log/login.log 2>&1 &",
timeout_seconds=30,
)
# Wait for the login to land before freezing the machine.
for _ in range(60):
log = warm.exec("cat /var/log/login.log", timeout_seconds=15).stdout
if "logged in:" in log:
break
time.sleep(2)
else:
raise RuntimeError("login never completed: " +
warm.exec("cat /var/log/login.log").stdout[-2000:])
snapshot_id = warm.snapshot() # memory + disk, ~30-60s on a big guest
print("baked snapshot", snapshot_id)
return warm.id, snapshot_idPhase two is the cheap part. Each task forks the warm sandbox, runs against a browser that is already authenticated, and throws the fork away.
# ---------------------------------------------------------------
# Phase 2: fork per task. 400-750ms same-host, 1.2-3.5s cross-host.
# ---------------------------------------------------------------
TASK_SCRIPT = r'''
import json, os, sys
from playwright.sync_api import sync_playwright
target = sys.argv[1]
with sync_playwright() as p:
# Same profile directory the snapshot was baked with -- already
# logged in, no login form, no CAPTCHA, no credential anywhere.
ctx = p.chromium.launch_persistent_context(
user_data_dir="/opt/profile", headless=True
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(target, wait_until="networkidle", timeout=45_000)
# Everything below is attacker-controlled text. It is DATA for the
# model, never instructions -- say so explicitly in your prompt and
# design as though the model will ignore you anyway.
print(json.dumps({
"url": page.url,
"title": page.title(),
"text": page.inner_text("body")[:8000],
"logged_in": page.locator("[data-testid=dashboard]").count() > 0,
}))
ctx.close()
'''
def run_authenticated_task(warm: Sandbox, target_url: str) -> dict:
"""Fork the logged-in snapshot and run one task against it."""
child = warm.fork(metadata={"kind": "task", "target": target_url})
try:
# Re-apply egress per fork. The netns is per sandbox, so rules
# from the parent are not inherited -- verify this, do not assume.
child.exec("bash /opt/session/egress.sh portal.example.com",
timeout_seconds=60, check=True)
child.filesystem.write("/opt/session/task.py", TASK_SCRIPT)
res = child.exec("python3 /opt/session/task.py " + target_url,
timeout_seconds=300)
if res.exit_code != 0:
raise RuntimeError(res.stderr[-4000:])
out = json.loads(res.stdout.strip().splitlines()[-1])
# The staleness check that saves you a confusing outage: if the
# fork woke up logged out, the base snapshot has expired and the
# bake job needs to run. Alert on this; do not silently retry.
if not out["logged_in"]:
raise RuntimeError("warm snapshot is stale -- re-bake the login")
return out
finally:
child.kill() # the fork dies; the logged-in original does not
def fan_out(warm: Sandbox, urls: list[str]) -> list[dict]:
"""Ten independent authenticated browsers, ten separate kernels."""
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=10) as pool:
return list(pool.map(lambda u: run_authenticated_task(warm, u), urls))The fan-out at the end is the bit that makes this more than a convenience. Ten forks of one logged-in state are ten VMs with ten kernels and ten copies of the session, sharing memory pages copy-on-write until they write. One of them getting rate-limited, logged out or fed a hostile page does not touch the other nine, and none of them can see the base snapshot.
Where PandaStack is the wrong answer
I would rather you skip us than be disappointed, so here is the honest boundary.
If what you want is a managed CDP endpoint with the hard parts handled — proxy rotation, fingerprint management, stealth, CAPTCHA solving, a live view you can watch a session in, a session API that already exists — buy a browser-infrastructure vendor. Browserbase, Steel, Hyperbrowser, Browserless and the scraping-API companies have spent years on exactly the unglamorous problems that determine whether your scraper works, and none of those problems are made easier by a microVM. A datacenter IP running fresh automated Chromium is the precise fingerprint anti-bot vendors are paid to catch, and Firecracker does not change that by one basis point. Nothing about our isolation model helps you get past Cloudflare.
Skip us too if your volume is small. A hundred browser sessions a day against public pages does not need a per-session kernel, and the engineering time you would spend building the session layer is worth more than the isolation. Use a hosted browser API and get on with the product.
And skip us if what you need is an enterprise remote-browser-isolation product with a policy console, URL categorisation, DLP and a report an auditor accepts. That is a different category with a different deliverable, and no amount of Firecracker substitutes for a compliance program.
Where the microVM substrate is the right call is narrower and specific. Agents that log into accounts with real authority. Multi-tenant products where one customer's browser agent must never share a kernel with another's. Regulated data that cannot sit in a shared profile directory. Volume where per-session pricing loses to raw VM-seconds. And the fork pattern, which as far as I know you cannot get from a CDP endpoint at all, because forking requires owning the machine.
What I would actually build in 2026
If someone handed me a browser-automation problem tomorrow, this is the order I would work in, and it is deliberately boring at the start.
- Write it as plain Playwright first, even if I expect to throw it away. It takes an afternoon and it tells me what the real difficulties are. Half the time the answer is that there is no agent problem here at all, just an automation problem someone described in agent language.
- Expose that Playwright as three or four narrow typed tools and let a model choose between them. This gets you the flexibility people actually want from an agent — natural-language input, sensible handling of ambiguous requests — at a fraction of the cost and none of the nondeterminism inside the browsing itself.
- Identify the specific steps that keep breaking. There are usually two or three. Replace only those with an AI-resolved step from a composable framework, and cache the resolution so the common case stays deterministic and free.
- Only reach for a loop-owning framework where the target is genuinely unknown at write time. Prototype with it, measure the token cost per task on your worst real page, and decide with a number rather than a vibe.
- Whichever framework wins, run each session in its own microVM with default-deny egress and a hard TTL, and get the login out of the per-run path by snapshotting an authenticated browser and forking it.
- Build outcome verification that does not go through the agent, and a confirmation gate on everything irreversible, before the first production run rather than after the first incident.
The category is genuinely good now, in a way it was not eighteen months ago. The observation formats are better, the grounding is better, the projects are maintained and the ergonomics have improved a lot. What has not changed is that a language model driving a browser is a nondeterministic component with the authority of a logged-in user, and the engineering that makes that safe is not in any of these libraries. It is in the layer around them: what the agent can reach, what it can spend, what it can break, and what happens to the machine afterwards.
Pick the framework that matches your control-flow needs. Then spend the rest of your time on the boring part, because the boring part is what decides whether this is a product or an incident.
Frequently asked questions
What is the best open-source browser-agent framework in 2026?
There is no single best one, because they solve different control-flow problems. browser-use is the strongest loop-owning framework for tasks where the page is unknown at write time: you hand it a goal in English and it drives, using a serialised list of interactive DOM elements as its observation. Stagehand is the best fit when you want to keep writing Playwright and only replace the two or three brittle steps with AI-resolved actions, with caching so the common case stays deterministic. Skyvern is vision-first and aimed at RPA-style work on structurally hostile portals and forms. A Playwright MCP server is the best choice if you already have an agent runtime with tool-calling, because you add a tool server instead of adopting a framework. And plain Playwright behind typed tools remains the right answer for known, stable, high-volume targets. Verify current capabilities against each project's own documentation.
Should I use a browser-agent framework or just write Playwright?
Write Playwright if you can enumerate the steps and the selectors will still be valid next month. It is deterministic, debuggable with a stack trace, costs no tokens for the browsing itself, and fails loudly in a way your existing alerting understands. The frameworks earn their cost specifically when the page structure is unknown at write time or changes frequently — a user pastes an arbitrary URL, or you support hundreds of site variants, or you are replacing selectors weekly. A good middle path is to write the script yourself and use an AI-resolved step only at the two or three places that keep breaking, caching the resolved action so the common case skips the model entirely.
Why are browser agents so expensive to run?
Because you pay for the page on every step, not once per task. A DOM-serialising framework builds a representation of the page's interactive elements and puts it in the prompt, the model acts, the page changes, and the framework builds a fresh representation for the next step. A ten-step task sends ten page representations. On dense pages such as product grids or admin consoles that representation is large, and adding a screenshot per step for vision grounding roughly doubles it again. Measure the prompt tokens per step on your worst real page before committing to an architecture; the two mitigations that work are caching resolved actions so repeat runs skip the model, and scoping the observation to the container you care about instead of the whole page.
How do I handle credentials when a browser agent logs in as me?
Never put a credential in a task string or a prompt — model providers log requests, your tracing captures prompts, and support engineers read traces, so a secret in a prompt is a published secret. Type credentials with code the model cannot see, before the agent loop starts. Better still, do the login once out of band, snapshot the authenticated browser, and fork that snapshot per task, so the password is typed once ever and no run replays a login form. Then bound the damage: use scoped credentials rather than a full-authority session, apply default-deny egress so an injected exfiltration URL does not resolve, require confirmation on irreversible actions, and destroy the session environment afterwards rather than reusing it.
What is the main failure mode of an AI browser agent?
Silently doing the wrong thing on a real site and reporting success. Traditional automation fails by crashing — a selector misses, an exception is thrown, alerting fires. A browser agent instead does something plausible but incorrect: it filters by the wrong date range, cancels the wrong subscription because two rows looked alike, or summarises an article it never actually got past the paywall to read, and then returns success because from inside the loop the task looked complete. The defences are verification independent of the agent (check the outcome via an API or database, never trust self-reported success), a confirmation gate on anything irreversible, narrow tools so fewer wrong actions are even possible, and logging the observation the model saw, not just the action it took.
How many concurrent browser agents can one machine run?
It is a memory question, and Chromium is not small — a real session runs to hundreds of megabytes resident and climbs past a gigabyte on heavy sites, so fifty concurrent sessions at roughly 700 MiB is about 35 GiB of RAM before a single model call. Sharing one Chromium process with a browser context per agent saves memory and gives real cookie and storage isolation, but it puts every concurrent agent in one process: one CPU-pinning page degrades all of them, a renderer crash can take out neighbours, and a renderer exploit chained with a sandbox escape faces the kernel every session shares. Measure per-session memory on your actual target rather than guessing, and plan for peak concurrency, not average.
Can I snapshot a logged-in browser and reuse it across runs?
Yes, and it is the highest-leverage pattern in browser automation. On PandaStack you log in once in a persistent sandbox, snapshot the whole machine — guest memory and disk, so the running browser and its live session are captured rather than just a cookie file — and then fork that snapshot per task, at 400 to 750 milliseconds for a same-host fork and roughly 1.2 to 3.5 seconds cross-host. Every run starts authenticated with no login form, no CAPTCHA and no credential in the path, and parallel forks are fully independent VMs. The operational caveat is that sessions expire: treat re-baking the login as a scheduled job, check on every run whether the fork woke up logged out, and alert on a stale snapshot rather than silently retrying.
Keep reading
- AI browser agents: one VM per task — The hosting side of this post — running whichever framework you picked with a disposable VM per task.
- Persisting a logged-in browser session — The three levels of session reuse: storage state, a persistent profile, and snapshotting the machine.
- Best remote browser isolation platforms (2026) — The layer below the framework: pixel vs DOM isolation, per-session boundaries, and prompt injection.
- Running a browser automation farm on microVMs — What changes when it is ten thousand sessions a day instead of ten.
- What tool calling actually is — The baseline this post keeps recommending: narrow typed tools the model chooses between.
- What the Model Context Protocol is — The protocol behind the Playwright MCP option: what it standardises, and what it does not.
- PandaStack for AI agents — Sandboxes, forks and scale-to-zero for agent workloads.
49ms p50 cold start. Fork, snapshot, and scale to zero.