From ddc8dc6bcc445ae7409da4de5ed8bb15da42f6f1 Mon Sep 17 00:00:00 2001 From: bb Date: Mon, 17 Aug 2026 17:01:02 +0000 Subject: [PATCH 01/14] [STG-2850] Add Browser Use to v4 migration guide --- packages/docs/docs.json | 2 +- packages/docs/v4/migrations/browser-use.mdx | 578 ++++++++++++++++++++ 2 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 packages/docs/v4/migrations/browser-use.mdx diff --git a/packages/docs/docs.json b/packages/docs/docs.json index b1aaadcd8..7d766a41b 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -72,7 +72,7 @@ }, { "group": "Migration guide", - "pages": ["v4/migrations/v3", "v4/migrations/playwright"] + "pages": ["v4/migrations/v3", "v4/migrations/playwright", "v4/migrations/browser-use"] }, { "group": "SDK reference", diff --git a/packages/docs/v4/migrations/browser-use.mdx b/packages/docs/v4/migrations/browser-use.mdx new file mode 100644 index 000000000..08d465180 --- /dev/null +++ b/packages/docs/v4/migrations/browser-use.mdx @@ -0,0 +1,578 @@ +--- +title: Migrate Browser Use to v4 +sidebarTitle: Migrate Browser Use to v4 +icon: 'robot' +--- + +Browser Use hands a task to an autonomous agent: you write `Agent(task="...", llm=...)`, call `run()`, and the agent decides every click and keystroke on its own. Stagehand v4 has no such object. There is no `Agent`, no `run()`, and nothing that takes a whole task and drives the browser end to end without you. + +So this isn't a rename pass. Browser Use is one primitive; v4 is a browser SDK plus three model-backed steps: [`act()`](/v4/basics/act), [`extract()`](/v4/basics/extract), and [`observe()`](/v4/basics/observe). Moving a flow means deciding, step by step, where a sentence of English is still worth a model call and where a selector does the job for free. + + +Browser Use is Python-first, so this guide leads with Python. Stagehand behaves the same way in TypeScript; the [SDK reference](/v4/reference/stagehand) carries each language's naming. + + +## Why there's no Agent + +`Agent(task=...).run()` was built for a world where a model couldn't be trusted with the browser on its own, so the framework wrapped it in a loop, showed it the page each step, and asked it to pick one action at a time. Every step was an inference call, whether the page needed judgement or not. + +That trade stopped being worth it. A per-step agent loop is slow, non-deterministic, and expensive precisely where it doesn't need to be: navigating to a URL, clicking a button with a stable selector, reading a table. v4 gives you the discrete tools and leaves the control flow to you. + +Two approaches replace the agent: + +- **[Code mode](#code-mode)** puts the model in front of the run, not inside it. A coding assistant writes a Stagehand script once; you run that script every time after. Browserbase recommends starting here. +- **[Tool calling](#tool-calling)** keeps a model in the loop at runtime, the way Browser Use does, but drives the browser through the full Stagehand API as its tools instead of one broad task string. + +Either way, `act()`, `extract()`, and `observe()` stay in your toolbox for the steps that genuinely need a model. You just stop handing a model the entire task. + +## Hello world, side by side + +The smallest Browser Use program and its v4 shape: + + +```python Browser Use +from browser_use import Agent, ChatOpenAI +from dotenv import load_dotenv +import asyncio + +load_dotenv() + + +async def main(): + agent = Agent( + task="Go to news.ycombinator.com and return the title of the top story", + llm=ChatOpenAI(model="gpt-4.1-mini"), + ) + history = await agent.run() + print(history.final_result()) + + +asyncio.run(main()) +``` + +```python Stagehand v4 +import asyncio +import os + +from stagehand import Stagehand, browserbase + + +async def main(): + browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"]) + stagehand = await Stagehand.create(browser=browser) + + try: + page = await browser.context.new_page("https://news.ycombinator.com") + # A stable selector does this without a model call. + title = await page.locator(".titleline > a").first().inner_text() + print(title) + finally: + await stagehand.close() + await browser.close() + + +asyncio.run(main()) +``` + + +The shape of the migration is already visible: + +- A browser factory (`browserbase.launch()` or `local_browser.launch()`) replaces the implicit browser inside `Agent`, and `Stagehand.create()` attaches the runtime to it. +- The task string is gone. You write the steps. +- Where Browser Use would have spent a model call reading the page, a `page.locator()` does it for free. Spend `act()` and `extract()` only where the page needs judgement. + + +Stagehand reads no environment variables of its own. Browser Use auto-loads `.env` and picks up `OPENAI_API_KEY`, `BROWSER_USE_API_KEY`, and friends. In v4 you pass every key explicitly: the Browserbase API key to the factory, and any model key in the `model` option. `load_dotenv()` still works to get values into `os.environ`; nothing reads them for you. + + +## Code mode + +Ask a coding assistant to write the Stagehand script, then run the script. The model writes the code once, instead of driving the browser on every run. You get ordinary code: reviewable, diffable, and free of per-step inference. When a site changes, re-run the assistant on the step that broke. + +Start with [AI rules](/v4/first-steps/ai-rules). Those rule files keep generated code on the v4 API instead of the older Stagehand and Browser Use patterns in a model's training data. + +Here's a prompt that produces a working script: + +```text +Using Stagehand v4, write a script that: + 1. Opens news.ycombinator.com + 2. Finds today's most-commented story + 3. Opens its comments and extracts the top five comment bodies + +Follow the rules in my project's Stagehand rules file. Prefer page.locator() +and page.goto() for anything with a stable selector, and reserve act() and +extract() for steps that need a model. +``` + +What comes back should read like the script you'd have written yourself: + + + +```python +import asyncio +import os + +from pydantic import BaseModel +from stagehand import Stagehand, browserbase + + +class Comment(BaseModel): + author: str + body: str + + +class Comments(BaseModel): + comments: list[Comment] + + +async def main() -> None: + browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"]) + stagehand = await Stagehand.create(browser=browser) + + try: + page = await browser.context.new_page("https://news.ycombinator.com") + + # Deterministic where the page allows it: no inference, no variance. + await page.locator("a.morelink").first().click() + await page.wait_for_load_state("domcontentloaded") + + # A model call where the page needs judgement. + await stagehand.act("Open the comments for the story with the most comments") + + result = await stagehand.extract( + "Extract the top five comments, with each author and body", + Comments, + ) + print(result.data.comments) + finally: + await stagehand.close() + await browser.close() + + +asyncio.run(main()) +``` + + + +```typescript +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod/v4"; + +const commentSchema = z.object({ + comments: z.array(z.object({ author: z.string(), body: z.string() })), +}); + +const browser = await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }); +const stagehand = await Stagehand.create({ browser }); + +try { + const page = await browser.context.newPage("https://news.ycombinator.com"); + + // Deterministic where the page allows it: no inference, no variance. + await page.locator("a.morelink").first().click(); + await page.waitForLoadState("domcontentloaded"); + + // A model call where the page needs judgement. + await stagehand.act("Open the comments for the story with the most comments"); + + const { data } = await stagehand.extract( + "Extract the top five comments, with each author and body", + commentSchema, + ); + console.log(data.comments); +} finally { + await stagehand.close(); + await browser.close(); +} +``` + + + +Generated code should use `page.locator()` and `page.goto()` wherever a selector is stable, and spend a model call only where the page needs judgement. A Browser Use agent couldn't make that split, because every step it ran was an inference call. + +## Tool calling + +To keep a model in the loop at runtime, the way Browser Use does, give it the whole Stagehand surface rather than one `task` string. Each method maps to one tool with a narrow contract, so a step names a specific browser operation instead of routing everything through one sentence of English and hoping the agent picks the right action. + +Browser Use also lets you register custom tools with `@tools.action(...)` (formerly `@controller.action(...)`). Those port directly: each becomes one function in the tool set below. + +Expose the real API: + +| Capability | Tools worth exposing | +| --- | --- | +| Navigation | `page.goto()`, `page.reload()`, `page.goBack()`, `page.goForward()` | +| Perception | `page.snapshot()`, `page.screenshot()`, `page.url()`, `page.title()` | +| Element interaction | `locator.click()`, `locator.fill()`, `locator.type()`, `locator.selectOption()`, `locator.setInputFiles()`, `locator.scrollTo()` | +| Element inspection | `locator.textContent()`, `locator.innerText()`, `locator.isVisible()`, `locator.isChecked()`, `locator.count()`, `locator.inputValue()` | +| Raw input | `page.click(x, y)`, `page.hover()`, `page.scroll()`, `page.type()`, `page.keyPress()`, `page.dragAndDrop()` | +| Tabs and state | `context.newPage()`, `context.pages()`, `context.setActivePage()`, `context.cookies()` | +| Waiting | `page.waitForSelector()`, `page.waitForLoadState()`, `page.waitForTimeout()` | +| Model-backed steps | `stagehand.act()`, `stagehand.extract()`, `stagehand.observe()` | +| Page-declared tools | `page.tools()`, see [WebMCP](/v4/basics/webmcp) | + +`page.snapshot()` anchors the loop the way Browser Use's page state did. It returns `formattedTree`, the accessibility tree, plus an `xpathMap`, so the model reads real page structure and hands back a selector you can drive deterministically. + + + +```python +async def goto(url: str) -> str: + """Navigate to a URL.""" + await page.goto(url) + return await page.url() + + +async def snapshot() -> str: + """Read the accessibility tree of the current page.""" + return (await page.snapshot()).formatted_tree + + +async def click(selector: str) -> None: + """Click the element matching a selector from the snapshot.""" + await page.locator(selector).click() + + +async def fill(selector: str, value: str) -> None: + """Fill the input matching a selector.""" + await page.locator(selector).fill(value) + + +async def read_text(selector: str) -> str: + """Read the text of the element matching a selector.""" + return await page.locator(selector).text_content() + + +async def act(instruction: str) -> str: + """Perform one action in natural language when no selector is known.""" + return (await stagehand.act(instruction)).data.message + + +async def extract(instruction: str) -> str: + """Read structured data off the current page.""" + return (await stagehand.extract(instruction)).data.extraction + + +TOOLS = [goto, snapshot, click, fill, read_text, act, extract] +``` + + + +```typescript +import { z } from "zod/v4"; + +const tools = { + goto: { + description: "Navigate to a URL", + parameters: z.object({ url: z.string() }), + execute: async ({ url }: { url: string }) => { + await page.goto(url); + return await page.url(); + }, + }, + snapshot: { + description: "Read the accessibility tree of the current page", + parameters: z.object({}), + execute: async () => (await page.snapshot()).formattedTree, + }, + click: { + description: "Click the element matching a selector from the snapshot", + parameters: z.object({ selector: z.string() }), + execute: async ({ selector }: { selector: string }) => { + await page.locator(selector).click(); + }, + }, + fill: { + description: "Fill the input matching a selector", + parameters: z.object({ selector: z.string(), value: z.string() }), + execute: async ({ selector, value }: { selector: string; value: string }) => { + await page.locator(selector).fill(value); + }, + }, + readText: { + description: "Read the text of the element matching a selector", + parameters: z.object({ selector: z.string() }), + execute: async ({ selector }: { selector: string }) => + await page.locator(selector).textContent(), + }, + act: { + description: "Perform one action in natural language when no selector is known", + parameters: z.object({ instruction: z.string() }), + execute: async ({ instruction }: { instruction: string }) => + (await stagehand.act(instruction)).data.message, + }, + extract: { + description: "Read structured data off the current page", + parameters: z.object({ instruction: z.string() }), + execute: async ({ instruction }: { instruction: string }) => + (await stagehand.extract(instruction)).data.extraction, + }, +}; +``` + + + + +Escalate on `observe()`, never on `act()`. A failed `act()` may already have clicked, submitted, or paid before the error surfaced, so retrying it can repeat the side effect. `observe()` only plans, so retrying it is free. Browser Use's `max_failures` retry loop had the same hazard; keep the retry on the planning step. [Cost optimization](/v4/best-practices/cost-optimization) applies the same idea to model escalation. + + +## Let a coding assistant do the rest + +Most of the mechanical mapping below is exactly what an assistant is good at. Point it at this page instead of retyping the rules: + +```text +Migrate this file from Browser Use to Stagehand v4. + +Follow https://docs.stagehand.dev/v4/migrations/browser-use, and use its quick +reference table as the mapping. For each Agent(task=...).run() call, stop and +ask me whether to replace it with a written script (code mode) or a tool-calling +loop, and list every one you find instead of guessing. +``` + +Set up [AI rules](/v4/first-steps/ai-rules) first, so the assistant stays on the v4 API instead of the Stagehand and Browser Use patterns in its training data. + +Then work through the sections below for anything it missed. + +## Recommended migration order + +1. Get one script constructing and closing cleanly on v4, before porting any behavior. Launch a browser, open a page, close both handles. +2. Replace `Agent(task=...).run()` with [code mode](#code-mode) or [tool calling](#tool-calling). This is the real work, and everything else is mechanical. +3. Convert the deterministic steps to `page.locator()` and `page.goto()`, keeping `act()` and `extract()` only where the page needs judgement. +4. Move `output_model_schema` to an `extract()` call with a schema. +5. Move `sensitive_data` to `variables` on `act()`. +6. Turn on server-side caching once the flow is stable. + +## Breaking changes + +### Initialization and teardown + +Browser Use constructs a browser inside the agent and closes it for you. v4 separates the browser from the runtime, and you close both: + + + +```diff +- from browser_use import Agent, ChatOpenAI +- +- agent = Agent(task="...", llm=ChatOpenAI(model="gpt-4.1-mini")) +- await agent.run() ++ import os ++ from stagehand import Stagehand, browserbase ++ ++ browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"]) ++ stagehand = await Stagehand.create(browser=browser) ++ try: ++ ... # your steps ++ finally: ++ await stagehand.close() ++ await browser.close() +``` + + + +```diff ++ import { browserbase, Stagehand } from "@browserbasehq/stagehand"; ++ ++ const browser = await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }); ++ const stagehand = await Stagehand.create({ browser }); ++ try { ++ // your steps ++ } finally { ++ await stagehand.close(); ++ await browser.close(); ++ } +``` + + + +Use `local_browser.launch()` for a browser on your machine, `browserbase.launch({ apiKey })` for a hosted one, and `local_browser.connect({ cdpUrl })` or `browserbase.connect({ apiKey, sessionId })` to attach to one that's already running. `browserbase.launch()` is what enables [server-side caching](/v4/best-practices/caching) and the [Model Gateway](/v4/configuration/models#model-gateway). Stagehand closes only the browsers it launched, so `stagehand.close()` leaves the browser running and you call `browser.close()` yourself. See [browser configuration](/v4/configuration/browser). + +### The task string becomes explicit steps + +There's no direct diff here, because a `task` string has no single replacement. That's the whole migration: what the agent inferred each step, you now write out. Read [code mode](#code-mode) and [tool calling](#tool-calling), pick one, and translate the intent of the task into steps. + +### Models + +Browser Use selects a provider by the chat class you construct (`ChatOpenAI`, `ChatAnthropic`, `ChatGoogle`, `ChatBrowserUse`). v4 takes one `model` object, and the model name always carries a provider prefix: + + + +```diff +- from browser_use import Agent, ChatAnthropic +- agent = Agent(task="...", llm=ChatAnthropic(model="claude-sonnet-4-0")) ++ stagehand = await Stagehand.create( ++ browser=browser, ++ model=ModelConfig( ++ model_name="anthropic/claude-sonnet-4-6", ++ api_key=os.environ["ANTHROPIC_API_KEY"], ++ ), ++ ) +``` + + + +```diff ++ const stagehand = await Stagehand.create({ ++ browser, ++ model: { ++ modelName: "anthropic/claude-sonnet-4-6", ++ apiKey: process.env.ANTHROPIC_API_KEY, ++ }, ++ }); +``` + + + +On a Browserbase browser you can omit `model` entirely and the Model Gateway picks one for you, which is the closest analogue to `ChatBrowserUse()`. Pass the same shape to a single `act()`, `extract()`, or `observe()` call to override it there. Browser Use's `page_extraction_llm` (a separate model for extraction) maps to passing `model` on the `extract()` call. See [models](/v4/configuration/models). + +### Structured output + +Browser Use validates the final result against `output_model_schema` on the agent. v4 puts the schema on the `extract()` call that reads the data, and returns it typed: + + + +```diff +- class SearchResult(BaseModel): +- title: str +- url: str +- agent = Agent(task="...", llm=llm, output_model_schema=SearchResult) +- history = await agent.run() +- result = history.structured_output ++ class SearchResult(BaseModel): ++ title: str ++ url: str ++ result = await stagehand.extract( ++ "Extract the title and URL of the top result", ++ SearchResult, ++ ) ++ # result.data is a SearchResult +``` + + + +```diff ++ const { data } = await stagehand.extract( ++ "Extract the title and URL of the top result", ++ z.object({ title: z.string(), url: z.url() }), ++ ); +``` + + + +Calling `extract()` with no schema returns `{ extraction: string }`. See [extract](/v4/basics/extract). + +### Sensitive data + +Both frameworks keep secrets out of the model's context. Browser Use uses a `sensitive_data` dict of placeholder-to-value; v4 uses `variables` with `%name%` placeholders in the instruction, on `act()` and `observe()`: + + + +```diff +- agent = Agent( +- task="Log in with x_user and x_pass", +- llm=llm, +- sensitive_data={"x_user": "user@example.com", "x_pass": os.environ["PW"]}, +- ) ++ await stagehand.act( ++ "type %username% into the email field", ++ variables={"username": "user@example.com"}, ++ ) ++ await stagehand.act( ++ "type %password% into the password field", ++ variables={"password": os.environ["PW"]}, ++ ) ++ await stagehand.act("click the login button") +``` + + + +```diff ++ await stagehand.act("type %username% into the email field", { ++ variables: { username: "user@example.com" }, ++ }); ++ await stagehand.act("type %password% into the password field", { ++ variables: { password: process.env.PW }, ++ }); ++ await stagehand.act("click the login button"); +``` + + + +Stagehand exposes only the variable names to the model and substitutes the real values locally. One exception: with [server-side caching](/v4/best-practices/caching) on, variable values travel to the cache service, so turn `cache` off for calls that carry credentials. See [act](/v4/basics/act#secure-your-automations). For Browser Use's TOTP support (`sensitive_data` keys ending in `bu_2fa_code`), generate the code in your own script and pass it as a variable; v4 has no built-in 2FA step. + +### Custom tools + +Browser Use's `@tools.action(...)` / `@controller.action(...)` decorators register functions the agent can call. In v4 there's no agent to register them with, so each custom action becomes an ordinary function you call directly in code mode, or one entry in your [tool-calling](#tool-calling) tool set. The function body ports as-is; only the registration goes away. + +### Browser configuration + +`Browser(...)` (aliased `BrowserSession`) options map onto the browser factory. The common ones: + +| Browser Use | Stagehand v4 | +| --- | --- | +| `Browser(headless=False)` | `local_browser.launch({ headless: false })` | +| `Browser(cdp_url="http://localhost:9222")` | `local_browser.connect({ cdpUrl: "http://localhost:9222" })` | +| `Browser(proxy=ProxySettings(...))` | `proxy` on `local_browser.launch()`, or Browserbase proxies | +| `Browser(allowed_domains=[...])` / `prohibited_domains=[...]` | `browser.context.setDomainPolicy({ allowedDomains, blockedDomains })` | +| `Browser(storage_state="auth.json")` | Cookie API on `browser.context`, or a [Browserbase context](/v4/best-practices/user-data) | +| `Browser(user_data_dir=...)` | `userDataDir` on `local_browser.launch()` | +| `Browser(accept_downloads=True, downloads_path=...)` | `acceptDownloads` and `downloadsPath` on `local_browser.launch()` | +| `Browser(keep_alive=True)` | `keepAlive` on `local_browser.launch()` | +| `@sandbox(...)` cloud deployment | `browserbase.launch({ apiKey })` plus [deployments](/v4/best-practices/deployments) | + +Browser Use's `@sandbox` decorator runs the agent next to the browser on cloud infrastructure. The v4 equivalent is a Browserbase browser: `browserbase.launch()` gives you the hosted session, and the [live view, recording, and network detail](/v4/configuration/observability) replace `@sandbox`'s `on_browser_created` / `live_url` and `on_log` callbacks. + +## Quick reference + +| Browser Use | Stagehand v4 | +| --- | --- | +| `Agent(task=..., llm=...)`, `agent.run()` | Code mode, or a tool-calling loop you own | +| The `task` string | Explicit steps: `page.locator()`, `act()`, `extract()` | +| `run(max_steps=...)` | Your loop bound, or the length of the script | +| `agent.add_new_task(...)` (follow-up) | Keep calling steps on the same browser | +| `ChatOpenAI`, `ChatAnthropic`, `ChatGoogle` | `model: { modelName: "provider/model", apiKey }` | +| `ChatBrowserUse()` | Omit `model` on a Browserbase browser (Model Gateway picks) | +| `page_extraction_llm` | `model` option on the `extract()` call | +| `output_model_schema=Model` | `extract(instruction, Model)` | +| `sensitive_data={...}` | `variables` on `act()` and `observe()` | +| `@tools.action(...)`, `@controller.action(...)` | A plain function, or one tool in your tool set | +| `tools` / `controller` registry | Your own tool set, see [tool calling](#tool-calling) | +| `history.final_result()` | The return of your last `extract()` | +| `history.structured_output` | `extract().data`, typed by the schema | +| `history.urls()`, `history.errors()` | Your own bookkeeping between steps | +| `use_vision=True` | `screenshot: true` on an `extract()` call | +| `generate_gif=True` | Browserbase [session recording](/v4/configuration/observability) | +| `Browser(...)` / `BrowserSession(...)` | `local_browser.launch()` or `browserbase.launch()` | +| `Browser(cdp_url=...)` | `local_browser.connect({ cdpUrl })` | +| `Browser(allowed_domains=...)` | `browser.context.setDomainPolicy({ allowedDomains })` | +| `Browser(storage_state=...)` | Cookie API, or a Browserbase context | +| `@sandbox(...)` | `browserbase.launch({ apiKey })` | +| `calculate_cost=True`, `history.usage` | `await stagehand.metrics()` | + +## Troubleshooting + +**`ImportError: cannot import name 'Agent'`.** There is no `Agent` in v4. Replace `Agent(task=...).run()` with [code mode](#code-mode) or [tool calling](#tool-calling). + +**Nothing reads my API key.** Stagehand reads no environment variables. Pass the Browserbase key to `browserbase.launch()` and any model key in the `model` option. `load_dotenv()` only populates `os.environ`; you still pass the values in. + +**My script has no `history` object to read results from.** There's no run history. The value you'd have read from `history.final_result()` or `history.structured_output` is the return of your last `extract()` call, on `.data`. + +**My custom `@tools.action` function has nowhere to register.** Call it directly in code mode, or add it to your tool set for [tool calling](#tool-calling). Only the decorator goes away; the function body is unchanged. + +**A retried step repeats a side effect.** You're retrying `act()`, the same hazard Browser Use's `max_failures` loop had. Retry `observe()` instead and pass the resulting action to `act()` once. + +**My generated script uses `Agent` or old Stagehand APIs.** The assistant is drawing on Browser Use and older Stagehand patterns in its training data. Install the rule files from [AI rules](/v4/first-steps/ai-rules). + +## Next steps + + + + Set your coding assistant up to write v4 code + + + Perform one action, or replay an observed one + + + Pull typed data, the home for structured output + + + Cut inference out of a stable flow + + From e3f219382cd79cf5d0f18afdde958c7440a19ee5 Mon Sep 17 00:00:00 2001 From: shriyatheunicorn Date: Tue, 18 Aug 2026 14:15:11 -0700 Subject: [PATCH 02/14] Apply suggestion from @akeimach Co-authored-by: Alyssa Keimach <7604716+akeimach@users.noreply.github.com> --- packages/docs/v4/migrations/browser-use.mdx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/docs/v4/migrations/browser-use.mdx b/packages/docs/v4/migrations/browser-use.mdx index 08d465180..66ce29dd0 100644 --- a/packages/docs/v4/migrations/browser-use.mdx +++ b/packages/docs/v4/migrations/browser-use.mdx @@ -4,9 +4,13 @@ sidebarTitle: Migrate Browser Use to v4 icon: 'robot' --- -Browser Use hands a task to an autonomous agent: you write `Agent(task="...", llm=...)`, call `run()`, and the agent decides every click and keystroke on its own. Stagehand v4 has no such object. There is no `Agent`, no `run()`, and nothing that takes a whole task and drives the browser end to end without you. +Browser Use hands a task to an autonomous agent: you write `Agent(task="...", llm=...)`, call `run()`, and the agent picks each action. Stagehand v4 has no equivalent object. -So this isn't a rename pass. Browser Use is one primitive; v4 is a browser SDK plus three model-backed steps: [`act()`](/v4/basics/act), [`extract()`](/v4/basics/extract), and [`observe()`](/v4/basics/observe). Moving a flow means deciding, step by step, where a sentence of English is still worth a model call and where a selector does the job for free. +Most of this migration hinges on these principles: +1. There is no `Agent`. Nothing in v4 takes a whole task and drives the browser for you. +2. v4 is a browser SDK plus three model-backed steps. [`act()`](/v4/basics/act), [`extract()`](/v4/basics/extract), and [`observe()`](/v4/basics/observe) take natural-language instructions. Everything else is ordinary browser control. + +Porting a flow means writing out the steps the agent used to infer, and deciding for each one whether it needs a model or a selector. Browser Use is Python-first, so this guide leads with Python. Stagehand behaves the same way in TypeScript; the [SDK reference](/v4/reference/stagehand) carries each language's naming. From 13c5327932ada1a8f4b7ef55d2fb68bd9ddb5141 Mon Sep 17 00:00:00 2001 From: shriyatheunicorn Date: Tue, 18 Aug 2026 14:15:29 -0700 Subject: [PATCH 03/14] Apply suggestion from @akeimach Co-authored-by: Alyssa Keimach <7604716+akeimach@users.noreply.github.com> --- packages/docs/v4/migrations/browser-use.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/v4/migrations/browser-use.mdx b/packages/docs/v4/migrations/browser-use.mdx index 66ce29dd0..50a286aa1 100644 --- a/packages/docs/v4/migrations/browser-use.mdx +++ b/packages/docs/v4/migrations/browser-use.mdx @@ -20,7 +20,7 @@ Browser Use is Python-first, so this guide leads with Python. Stagehand behaves `Agent(task=...).run()` was built for a world where a model couldn't be trusted with the browser on its own, so the framework wrapped it in a loop, showed it the page each step, and asked it to pick one action at a time. Every step was an inference call, whether the page needed judgement or not. -That trade stopped being worth it. A per-step agent loop is slow, non-deterministic, and expensive precisely where it doesn't need to be: navigating to a URL, clicking a button with a stable selector, reading a table. v4 gives you the discrete tools and leaves the control flow to you. +Many of those steps do not need a model: navigating to a URL, clicking a button with a stable selector, reading a table. v4 exposes discrete tools and leaves the control flow to you. Two approaches replace the agent: From fba06f40e3c8e38cf6f998d24782fcf4a99d16da Mon Sep 17 00:00:00 2001 From: shriyatheunicorn Date: Thu, 20 Aug 2026 10:10:58 -0700 Subject: [PATCH 04/14] Update packages/docs/v4/migrations/browser-use.mdx Co-authored-by: Alyssa Keimach <7604716+akeimach@users.noreply.github.com> --- packages/docs/v4/migrations/browser-use.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/v4/migrations/browser-use.mdx b/packages/docs/v4/migrations/browser-use.mdx index 50a286aa1..3fe0afcc7 100644 --- a/packages/docs/v4/migrations/browser-use.mdx +++ b/packages/docs/v4/migrations/browser-use.mdx @@ -560,7 +560,7 @@ Browser Use's `@sandbox` decorator runs the agent next to the browser on cloud i **My custom `@tools.action` function has nowhere to register.** Call it directly in code mode, or add it to your tool set for [tool calling](#tool-calling). Only the decorator goes away; the function body is unchanged. -**A retried step repeats a side effect.** You're retrying `act()`, the same hazard Browser Use's `max_failures` loop had. Retry `observe()` instead and pass the resulting action to `act()` once. +**A retried step repeats a side effect.** You're retrying `act()`. Retry `observe()` instead and pass the resulting action to `act()` once. **My generated script uses `Agent` or old Stagehand APIs.** The assistant is drawing on Browser Use and older Stagehand patterns in its training data. Install the rule files from [AI rules](/v4/first-steps/ai-rules). From 1f5860976840f6f890c1f0c3066c3b28606a85b2 Mon Sep 17 00:00:00 2001 From: shriyatheunicorn Date: Thu, 20 Aug 2026 10:11:07 -0700 Subject: [PATCH 05/14] Update packages/docs/v4/migrations/browser-use.mdx Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- packages/docs/v4/migrations/browser-use.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/docs/v4/migrations/browser-use.mdx b/packages/docs/v4/migrations/browser-use.mdx index 3fe0afcc7..6eedbc35b 100644 --- a/packages/docs/v4/migrations/browser-use.mdx +++ b/packages/docs/v4/migrations/browser-use.mdx @@ -403,11 +403,11 @@ Browser Use selects a provider by the chat class you construct (`ChatOpenAI`, `C - agent = Agent(task="...", llm=ChatAnthropic(model="claude-sonnet-4-0")) + stagehand = await Stagehand.create( + browser=browser, -+ model=ModelConfig( -+ model_name="anthropic/claude-sonnet-4-6", -+ api_key=os.environ["ANTHROPIC_API_KEY"], -+ ), -+ ) +stagehand = await Stagehand.create( + browser=browser, + model="anthropic/claude-sonnet-4-6", + model_api_key=os.environ["ANTHROPIC_API_KEY"], +) ``` From 454f61e61e935f24eace78dd99e9d900bd282b7b Mon Sep 17 00:00:00 2001 From: shriyatheunicorn Date: Thu, 20 Aug 2026 10:11:51 -0700 Subject: [PATCH 06/14] Update packages/docs/v4/migrations/browser-use.mdx Co-authored-by: Alyssa Keimach <7604716+akeimach@users.noreply.github.com> --- packages/docs/v4/migrations/browser-use.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/v4/migrations/browser-use.mdx b/packages/docs/v4/migrations/browser-use.mdx index 6eedbc35b..b044c8ff3 100644 --- a/packages/docs/v4/migrations/browser-use.mdx +++ b/packages/docs/v4/migrations/browser-use.mdx @@ -27,7 +27,7 @@ Two approaches replace the agent: - **[Code mode](#code-mode)** puts the model in front of the run, not inside it. A coding assistant writes a Stagehand script once; you run that script every time after. Browserbase recommends starting here. - **[Tool calling](#tool-calling)** keeps a model in the loop at runtime, the way Browser Use does, but drives the browser through the full Stagehand API as its tools instead of one broad task string. -Either way, `act()`, `extract()`, and `observe()` stay in your toolbox for the steps that genuinely need a model. You just stop handing a model the entire task. +`act()`, `extract()`, and `observe()` stay available for the steps where a natural-language instruction beats a selector. ## Hello world, side by side From e2419f42dedf6a80546fd90a00aef78946086692 Mon Sep 17 00:00:00 2001 From: shriyatheunicorn Date: Thu, 20 Aug 2026 10:12:06 -0700 Subject: [PATCH 07/14] Update packages/docs/v4/migrations/browser-use.mdx Co-authored-by: Alyssa Keimach <7604716+akeimach@users.noreply.github.com> --- packages/docs/v4/migrations/browser-use.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/v4/migrations/browser-use.mdx b/packages/docs/v4/migrations/browser-use.mdx index b044c8ff3..23673c454 100644 --- a/packages/docs/v4/migrations/browser-use.mdx +++ b/packages/docs/v4/migrations/browser-use.mdx @@ -562,7 +562,7 @@ Browser Use's `@sandbox` decorator runs the agent next to the browser on cloud i **A retried step repeats a side effect.** You're retrying `act()`. Retry `observe()` instead and pass the resulting action to `act()` once. -**My generated script uses `Agent` or old Stagehand APIs.** The assistant is drawing on Browser Use and older Stagehand patterns in its training data. Install the rule files from [AI rules](/v4/first-steps/ai-rules). +**Your generated script uses `Agent` or old Stagehand APIs.** The assistant is drawing on Browser Use and older Stagehand patterns in its training data. Install the rule files from [AI rules](/v4/first-steps/ai-rules). ## Next steps From c4e286b7b58585f225472387c5050395df601c89 Mon Sep 17 00:00:00 2001 From: shriyatheunicorn Date: Thu, 20 Aug 2026 10:12:34 -0700 Subject: [PATCH 08/14] Update packages/docs/v4/migrations/browser-use.mdx Co-authored-by: Alyssa Keimach <7604716+akeimach@users.noreply.github.com> --- packages/docs/v4/migrations/browser-use.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/v4/migrations/browser-use.mdx b/packages/docs/v4/migrations/browser-use.mdx index 23673c454..2dd1796a2 100644 --- a/packages/docs/v4/migrations/browser-use.mdx +++ b/packages/docs/v4/migrations/browser-use.mdx @@ -83,7 +83,7 @@ The shape of the migration is already visible: - A browser factory (`browserbase.launch()` or `local_browser.launch()`) replaces the implicit browser inside `Agent`, and `Stagehand.create()` attaches the runtime to it. - The task string is gone. You write the steps. -- Where Browser Use would have spent a model call reading the page, a `page.locator()` does it for free. Spend `act()` and `extract()` only where the page needs judgement. +- Where Browser Use would have spent a model call reading the page, a `page.locator()` reads it directly. Spend `act()` and `extract()` only where the page needs judgement. Stagehand reads no environment variables of its own. Browser Use auto-loads `.env` and picks up `OPENAI_API_KEY`, `BROWSER_USE_API_KEY`, and friends. In v4 you pass every key explicitly: the Browserbase API key to the factory, and any model key in the `model` option. `load_dotenv()` still works to get values into `os.environ`; nothing reads them for you. From fac3725d4f8ad63eecd555bbab3c6ecdaf8f1bfc Mon Sep 17 00:00:00 2001 From: shriyatheunicorn Date: Thu, 20 Aug 2026 10:15:15 -0700 Subject: [PATCH 09/14] Update packages/docs/v4/migrations/browser-use.mdx Co-authored-by: Alyssa Keimach <7604716+akeimach@users.noreply.github.com> --- packages/docs/v4/migrations/browser-use.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/v4/migrations/browser-use.mdx b/packages/docs/v4/migrations/browser-use.mdx index 2dd1796a2..7b0c8d4e2 100644 --- a/packages/docs/v4/migrations/browser-use.mdx +++ b/packages/docs/v4/migrations/browser-use.mdx @@ -558,7 +558,7 @@ Browser Use's `@sandbox` decorator runs the agent next to the browser on cloud i **My script has no `history` object to read results from.** There's no run history. The value you'd have read from `history.final_result()` or `history.structured_output` is the return of your last `extract()` call, on `.data`. -**My custom `@tools.action` function has nowhere to register.** Call it directly in code mode, or add it to your tool set for [tool calling](#tool-calling). Only the decorator goes away; the function body is unchanged. +**A `@tools.action` function has nowhere to register.** Call it directly in code mode, or add it to your tool set for [tool calling](#tool-calling). Only the decorator goes away; the function body is unchanged. **A retried step repeats a side effect.** You're retrying `act()`. Retry `observe()` instead and pass the resulting action to `act()` once. From 44e4e005c9e1cf8d21774f2e74576cc6c54a6bca Mon Sep 17 00:00:00 2001 From: shriyatheunicorn Date: Thu, 20 Aug 2026 10:15:23 -0700 Subject: [PATCH 10/14] Update packages/docs/v4/migrations/browser-use.mdx Co-authored-by: Alyssa Keimach <7604716+akeimach@users.noreply.github.com> --- packages/docs/v4/migrations/browser-use.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/v4/migrations/browser-use.mdx b/packages/docs/v4/migrations/browser-use.mdx index 7b0c8d4e2..ad77ea280 100644 --- a/packages/docs/v4/migrations/browser-use.mdx +++ b/packages/docs/v4/migrations/browser-use.mdx @@ -554,7 +554,7 @@ Browser Use's `@sandbox` decorator runs the agent next to the browser on cloud i **`ImportError: cannot import name 'Agent'`.** There is no `Agent` in v4. Replace `Agent(task=...).run()` with [code mode](#code-mode) or [tool calling](#tool-calling). -**Nothing reads my API key.** Stagehand reads no environment variables. Pass the Browserbase key to `browserbase.launch()` and any model key in the `model` option. `load_dotenv()` only populates `os.environ`; you still pass the values in. +**`KeyError: 'BROWSERBASE_API_KEY'`.** Stagehand reads no environment variables. Pass the Browserbase key to `browserbase.launch()` and any model key in the `model` option. `load_dotenv()` only populates `os.environ`; you still pass the values in. **My script has no `history` object to read results from.** There's no run history. The value you'd have read from `history.final_result()` or `history.structured_output` is the return of your last `extract()` call, on `.data`. From 65581d9be2207581719445b66888a317d96d522f Mon Sep 17 00:00:00 2001 From: shriyatheunicorn Date: Thu, 20 Aug 2026 10:15:30 -0700 Subject: [PATCH 11/14] Update packages/docs/v4/migrations/browser-use.mdx Co-authored-by: Alyssa Keimach <7604716+akeimach@users.noreply.github.com> --- packages/docs/v4/migrations/browser-use.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/v4/migrations/browser-use.mdx b/packages/docs/v4/migrations/browser-use.mdx index ad77ea280..b7eb08dc6 100644 --- a/packages/docs/v4/migrations/browser-use.mdx +++ b/packages/docs/v4/migrations/browser-use.mdx @@ -86,7 +86,7 @@ The shape of the migration is already visible: - Where Browser Use would have spent a model call reading the page, a `page.locator()` reads it directly. Spend `act()` and `extract()` only where the page needs judgement. -Stagehand reads no environment variables of its own. Browser Use auto-loads `.env` and picks up `OPENAI_API_KEY`, `BROWSER_USE_API_KEY`, and friends. In v4 you pass every key explicitly: the Browserbase API key to the factory, and any model key in the `model` option. `load_dotenv()` still works to get values into `os.environ`; nothing reads them for you. +Stagehand reads no environment variables of its own. Browser Use auto-loads `.env` and picks up `OPENAI_API_KEY` and `BROWSER_USE_API_KEY`. In v4 you pass every key explicitly: the Browserbase API key to the factory, and any model key in the `model` option. `load_dotenv()` still populates `os.environ`, but you pass in the values yourself. ## Code mode From 45df5f97996aa0d6bef3749e46a78ab0887ca538 Mon Sep 17 00:00:00 2001 From: shriyatheunicorn Date: Thu, 20 Aug 2026 10:15:49 -0700 Subject: [PATCH 12/14] Update packages/docs/v4/migrations/browser-use.mdx Co-authored-by: Alyssa Keimach <7604716+akeimach@users.noreply.github.com> --- packages/docs/v4/migrations/browser-use.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/v4/migrations/browser-use.mdx b/packages/docs/v4/migrations/browser-use.mdx index b7eb08dc6..558d2847e 100644 --- a/packages/docs/v4/migrations/browser-use.mdx +++ b/packages/docs/v4/migrations/browser-use.mdx @@ -556,7 +556,7 @@ Browser Use's `@sandbox` decorator runs the agent next to the browser on cloud i **`KeyError: 'BROWSERBASE_API_KEY'`.** Stagehand reads no environment variables. Pass the Browserbase key to `browserbase.launch()` and any model key in the `model` option. `load_dotenv()` only populates `os.environ`; you still pass the values in. -**My script has no `history` object to read results from.** There's no run history. The value you'd have read from `history.final_result()` or `history.structured_output` is the return of your last `extract()` call, on `.data`. +**`AttributeError` on `history` or `final_result()`.** There's no run history. The value you'd have read from `history.final_result()` or `history.structured_output` is the return of your last `extract()` call, on `.data`. **A `@tools.action` function has nowhere to register.** Call it directly in code mode, or add it to your tool set for [tool calling](#tool-calling). Only the decorator goes away; the function body is unchanged. From b8ce684d814329c1b9d46d3ae1963841846854b7 Mon Sep 17 00:00:00 2001 From: bb Date: Thu, 20 Aug 2026 21:28:41 +0000 Subject: [PATCH 13/14] docs(v4): make Browser Use migration guide Python-only Remove the TypeScript tabs from the Browser Use to v4 migration guide so each example is a single Python block, matching the single-language style of the Playwright guide. Convert the API-surface and quick-reference tables and inline prose to Python idioms (snake_case, keyword args). Also fixes a garbled diff in the Models section. --- packages/docs/v4/migrations/browser-use.mdx | 212 +++----------------- 1 file changed, 25 insertions(+), 187 deletions(-) diff --git a/packages/docs/v4/migrations/browser-use.mdx b/packages/docs/v4/migrations/browser-use.mdx index 558d2847e..8132885b4 100644 --- a/packages/docs/v4/migrations/browser-use.mdx +++ b/packages/docs/v4/migrations/browser-use.mdx @@ -13,7 +13,7 @@ Most of this migration hinges on these principles: Porting a flow means writing out the steps the agent used to infer, and deciding for each one whether it needs a model or a selector. -Browser Use is Python-first, so this guide leads with Python. Stagehand behaves the same way in TypeScript; the [SDK reference](/v4/reference/stagehand) carries each language's naming. +Browser Use is Python-first, so this guide is Python throughout. Stagehand behaves the same way in TypeScript; the [SDK reference](/v4/reference/stagehand) carries each language's naming. ## Why there's no Agent @@ -110,8 +110,6 @@ extract() for steps that need a model. What comes back should read like the script you'd have written yourself: - - ```python import asyncio import os @@ -155,42 +153,6 @@ async def main() -> None: asyncio.run(main()) ``` - - - -```typescript -import { browserbase, Stagehand } from "@browserbasehq/stagehand"; -import { z } from "zod/v4"; - -const commentSchema = z.object({ - comments: z.array(z.object({ author: z.string(), body: z.string() })), -}); - -const browser = await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }); -const stagehand = await Stagehand.create({ browser }); - -try { - const page = await browser.context.newPage("https://news.ycombinator.com"); - - // Deterministic where the page allows it: no inference, no variance. - await page.locator("a.morelink").first().click(); - await page.waitForLoadState("domcontentloaded"); - - // A model call where the page needs judgement. - await stagehand.act("Open the comments for the story with the most comments"); - - const { data } = await stagehand.extract( - "Extract the top five comments, with each author and body", - commentSchema, - ); - console.log(data.comments); -} finally { - await stagehand.close(); - await browser.close(); -} -``` - - Generated code should use `page.locator()` and `page.goto()` wherever a selector is stable, and spend a model call only where the page needs judgement. A Browser Use agent couldn't make that split, because every step it ran was an inference call. @@ -204,20 +166,18 @@ Expose the real API: | Capability | Tools worth exposing | | --- | --- | -| Navigation | `page.goto()`, `page.reload()`, `page.goBack()`, `page.goForward()` | +| Navigation | `page.goto()`, `page.reload()`, `page.go_back()`, `page.go_forward()` | | Perception | `page.snapshot()`, `page.screenshot()`, `page.url()`, `page.title()` | -| Element interaction | `locator.click()`, `locator.fill()`, `locator.type()`, `locator.selectOption()`, `locator.setInputFiles()`, `locator.scrollTo()` | -| Element inspection | `locator.textContent()`, `locator.innerText()`, `locator.isVisible()`, `locator.isChecked()`, `locator.count()`, `locator.inputValue()` | -| Raw input | `page.click(x, y)`, `page.hover()`, `page.scroll()`, `page.type()`, `page.keyPress()`, `page.dragAndDrop()` | -| Tabs and state | `context.newPage()`, `context.pages()`, `context.setActivePage()`, `context.cookies()` | -| Waiting | `page.waitForSelector()`, `page.waitForLoadState()`, `page.waitForTimeout()` | +| Element interaction | `locator.click()`, `locator.fill()`, `locator.type()`, `locator.select_option()`, `locator.set_input_files()`, `locator.scroll_to()` | +| Element inspection | `locator.text_content()`, `locator.inner_text()`, `locator.is_visible()`, `locator.is_checked()`, `locator.count()`, `locator.input_value()` | +| Raw input | `page.click(x, y)`, `page.hover()`, `page.scroll()`, `page.type()`, `page.key_press()`, `page.drag_and_drop()` | +| Tabs and state | `context.new_page()`, `context.pages()`, `context.set_active_page()`, `context.cookies()` | +| Waiting | `page.wait_for_selector()`, `page.wait_for_load_state()`, `page.wait_for_timeout()` | | Model-backed steps | `stagehand.act()`, `stagehand.extract()`, `stagehand.observe()` | | Page-declared tools | `page.tools()`, see [WebMCP](/v4/basics/webmcp) | -`page.snapshot()` anchors the loop the way Browser Use's page state did. It returns `formattedTree`, the accessibility tree, plus an `xpathMap`, so the model reads real page structure and hands back a selector you can drive deterministically. +`page.snapshot()` anchors the loop the way Browser Use's page state did. It returns `formatted_tree`, the accessibility tree, plus an `xpath_map`, so the model reads real page structure and hands back a selector you can drive deterministically. - - ```python async def goto(url: str) -> str: """Navigate to a URL.""" @@ -257,62 +217,6 @@ async def extract(instruction: str) -> str: TOOLS = [goto, snapshot, click, fill, read_text, act, extract] ``` - - - -```typescript -import { z } from "zod/v4"; - -const tools = { - goto: { - description: "Navigate to a URL", - parameters: z.object({ url: z.string() }), - execute: async ({ url }: { url: string }) => { - await page.goto(url); - return await page.url(); - }, - }, - snapshot: { - description: "Read the accessibility tree of the current page", - parameters: z.object({}), - execute: async () => (await page.snapshot()).formattedTree, - }, - click: { - description: "Click the element matching a selector from the snapshot", - parameters: z.object({ selector: z.string() }), - execute: async ({ selector }: { selector: string }) => { - await page.locator(selector).click(); - }, - }, - fill: { - description: "Fill the input matching a selector", - parameters: z.object({ selector: z.string(), value: z.string() }), - execute: async ({ selector, value }: { selector: string; value: string }) => { - await page.locator(selector).fill(value); - }, - }, - readText: { - description: "Read the text of the element matching a selector", - parameters: z.object({ selector: z.string() }), - execute: async ({ selector }: { selector: string }) => - await page.locator(selector).textContent(), - }, - act: { - description: "Perform one action in natural language when no selector is known", - parameters: z.object({ instruction: z.string() }), - execute: async ({ instruction }: { instruction: string }) => - (await stagehand.act(instruction)).data.message, - }, - extract: { - description: "Read structured data off the current page", - parameters: z.object({ instruction: z.string() }), - execute: async ({ instruction }: { instruction: string }) => - (await stagehand.extract(instruction)).data.extraction, - }, -}; -``` - - Escalate on `observe()`, never on `act()`. A failed `act()` may already have clicked, submitted, or paid before the error surfaced, so retrying it can repeat the side effect. `observe()` only plans, so retrying it is free. Browser Use's `max_failures` retry loop had the same hazard; keep the retry on the planning step. [Cost optimization](/v4/best-practices/cost-optimization) applies the same idea to model escalation. @@ -350,8 +254,6 @@ Then work through the sections below for anything it missed. Browser Use constructs a browser inside the agent and closes it for you. v4 separates the browser from the runtime, and you close both: - - ```diff - from browser_use import Agent, ChatOpenAI - @@ -368,25 +270,8 @@ Browser Use constructs a browser inside the agent and closes it for you. v4 sepa + await stagehand.close() + await browser.close() ``` - - - -```diff -+ import { browserbase, Stagehand } from "@browserbasehq/stagehand"; -+ -+ const browser = await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }); -+ const stagehand = await Stagehand.create({ browser }); -+ try { -+ // your steps -+ } finally { -+ await stagehand.close(); -+ await browser.close(); -+ } -``` - - -Use `local_browser.launch()` for a browser on your machine, `browserbase.launch({ apiKey })` for a hosted one, and `local_browser.connect({ cdpUrl })` or `browserbase.connect({ apiKey, sessionId })` to attach to one that's already running. `browserbase.launch()` is what enables [server-side caching](/v4/best-practices/caching) and the [Model Gateway](/v4/configuration/models#model-gateway). Stagehand closes only the browsers it launched, so `stagehand.close()` leaves the browser running and you call `browser.close()` yourself. See [browser configuration](/v4/configuration/browser). +Use `local_browser.launch()` for a browser on your machine, `browserbase.launch(api_key=...)` for a hosted one, and `local_browser.connect(cdp_url=...)` or `browserbase.connect(api_key=..., session_id=...)` to attach to one that's already running. `browserbase.launch()` is what enables [server-side caching](/v4/best-practices/caching) and the [Model Gateway](/v4/configuration/models#model-gateway). Stagehand closes only the browsers it launched, so `stagehand.close()` leaves the browser running and you call `browser.close()` yourself. See [browser configuration](/v4/configuration/browser). ### The task string becomes explicit steps @@ -396,33 +281,15 @@ There's no direct diff here, because a `task` string has no single replacement. Browser Use selects a provider by the chat class you construct (`ChatOpenAI`, `ChatAnthropic`, `ChatGoogle`, `ChatBrowserUse`). v4 takes one `model` object, and the model name always carries a provider prefix: - - ```diff - from browser_use import Agent, ChatAnthropic - agent = Agent(task="...", llm=ChatAnthropic(model="claude-sonnet-4-0")) + stagehand = await Stagehand.create( + browser=browser, -stagehand = await Stagehand.create( - browser=browser, - model="anthropic/claude-sonnet-4-6", - model_api_key=os.environ["ANTHROPIC_API_KEY"], -) -``` - - - -```diff -+ const stagehand = await Stagehand.create({ -+ browser, -+ model: { -+ modelName: "anthropic/claude-sonnet-4-6", -+ apiKey: process.env.ANTHROPIC_API_KEY, -+ }, -+ }); ++ model="anthropic/claude-sonnet-4-6", ++ model_api_key=os.environ["ANTHROPIC_API_KEY"], ++ ) ``` - - On a Browserbase browser you can omit `model` entirely and the Model Gateway picks one for you, which is the closest analogue to `ChatBrowserUse()`. Pass the same shape to a single `act()`, `extract()`, or `observe()` call to override it there. Browser Use's `page_extraction_llm` (a separate model for extraction) maps to passing `model` on the `extract()` call. See [models](/v4/configuration/models). @@ -430,8 +297,6 @@ On a Browserbase browser you can omit `model` entirely and the Model Gateway pic Browser Use validates the final result against `output_model_schema` on the agent. v4 puts the schema on the `extract()` call that reads the data, and returns it typed: - - ```diff - class SearchResult(BaseModel): - title: str @@ -448,26 +313,13 @@ Browser Use validates the final result against `output_model_schema` on the agen + ) + # result.data is a SearchResult ``` - - - -```diff -+ const { data } = await stagehand.extract( -+ "Extract the title and URL of the top result", -+ z.object({ title: z.string(), url: z.url() }), -+ ); -``` - - -Calling `extract()` with no schema returns `{ extraction: string }`. See [extract](/v4/basics/extract). +Calling `extract()` with no schema returns an object whose `extraction` field is a string. See [extract](/v4/basics/extract). ### Sensitive data Both frameworks keep secrets out of the model's context. Browser Use uses a `sensitive_data` dict of placeholder-to-value; v4 uses `variables` with `%name%` placeholders in the instruction, on `act()` and `observe()`: - - ```diff - agent = Agent( - task="Log in with x_user and x_pass", @@ -484,20 +336,6 @@ Both frameworks keep secrets out of the model's context. Browser Use uses a `sen + ) + await stagehand.act("click the login button") ``` - - - -```diff -+ await stagehand.act("type %username% into the email field", { -+ variables: { username: "user@example.com" }, -+ }); -+ await stagehand.act("type %password% into the password field", { -+ variables: { password: process.env.PW }, -+ }); -+ await stagehand.act("click the login button"); -``` - - Stagehand exposes only the variable names to the model and substitutes the real values locally. One exception: with [server-side caching](/v4/best-practices/caching) on, variable values travel to the cache service, so turn `cache` off for calls that carry credentials. See [act](/v4/basics/act#secure-your-automations). For Browser Use's TOTP support (`sensitive_data` keys ending in `bu_2fa_code`), generate the code in your own script and pass it as a variable; v4 has no built-in 2FA step. @@ -511,15 +349,15 @@ Browser Use's `@tools.action(...)` / `@controller.action(...)` decorators regist | Browser Use | Stagehand v4 | | --- | --- | -| `Browser(headless=False)` | `local_browser.launch({ headless: false })` | -| `Browser(cdp_url="http://localhost:9222")` | `local_browser.connect({ cdpUrl: "http://localhost:9222" })` | +| `Browser(headless=False)` | `local_browser.launch(headless=False)` | +| `Browser(cdp_url="http://localhost:9222")` | `local_browser.connect(cdp_url="http://localhost:9222")` | | `Browser(proxy=ProxySettings(...))` | `proxy` on `local_browser.launch()`, or Browserbase proxies | -| `Browser(allowed_domains=[...])` / `prohibited_domains=[...]` | `browser.context.setDomainPolicy({ allowedDomains, blockedDomains })` | +| `Browser(allowed_domains=[...])` / `prohibited_domains=[...]` | `browser.context.set_domain_policy(allowed_domains=..., blocked_domains=...)` | | `Browser(storage_state="auth.json")` | Cookie API on `browser.context`, or a [Browserbase context](/v4/best-practices/user-data) | -| `Browser(user_data_dir=...)` | `userDataDir` on `local_browser.launch()` | -| `Browser(accept_downloads=True, downloads_path=...)` | `acceptDownloads` and `downloadsPath` on `local_browser.launch()` | -| `Browser(keep_alive=True)` | `keepAlive` on `local_browser.launch()` | -| `@sandbox(...)` cloud deployment | `browserbase.launch({ apiKey })` plus [deployments](/v4/best-practices/deployments) | +| `Browser(user_data_dir=...)` | `user_data_dir` on `local_browser.launch()` | +| `Browser(accept_downloads=True, downloads_path=...)` | `accept_downloads` and `downloads_path` on `local_browser.launch()` | +| `Browser(keep_alive=True)` | `keep_alive` on `local_browser.launch()` | +| `@sandbox(...)` cloud deployment | `browserbase.launch(api_key=...)` plus [deployments](/v4/best-practices/deployments) | Browser Use's `@sandbox` decorator runs the agent next to the browser on cloud infrastructure. The v4 equivalent is a Browserbase browser: `browserbase.launch()` gives you the hosted session, and the [live view, recording, and network detail](/v4/configuration/observability) replace `@sandbox`'s `on_browser_created` / `live_url` and `on_log` callbacks. @@ -531,7 +369,7 @@ Browser Use's `@sandbox` decorator runs the agent next to the browser on cloud i | The `task` string | Explicit steps: `page.locator()`, `act()`, `extract()` | | `run(max_steps=...)` | Your loop bound, or the length of the script | | `agent.add_new_task(...)` (follow-up) | Keep calling steps on the same browser | -| `ChatOpenAI`, `ChatAnthropic`, `ChatGoogle` | `model: { modelName: "provider/model", apiKey }` | +| `ChatOpenAI`, `ChatAnthropic`, `ChatGoogle` | `model="provider/model"`, `model_api_key=...` | | `ChatBrowserUse()` | Omit `model` on a Browserbase browser (Model Gateway picks) | | `page_extraction_llm` | `model` option on the `extract()` call | | `output_model_schema=Model` | `extract(instruction, Model)` | @@ -541,13 +379,13 @@ Browser Use's `@sandbox` decorator runs the agent next to the browser on cloud i | `history.final_result()` | The return of your last `extract()` | | `history.structured_output` | `extract().data`, typed by the schema | | `history.urls()`, `history.errors()` | Your own bookkeeping between steps | -| `use_vision=True` | `screenshot: true` on an `extract()` call | +| `use_vision=True` | `screenshot=True` on an `extract()` call | | `generate_gif=True` | Browserbase [session recording](/v4/configuration/observability) | | `Browser(...)` / `BrowserSession(...)` | `local_browser.launch()` or `browserbase.launch()` | -| `Browser(cdp_url=...)` | `local_browser.connect({ cdpUrl })` | -| `Browser(allowed_domains=...)` | `browser.context.setDomainPolicy({ allowedDomains })` | +| `Browser(cdp_url=...)` | `local_browser.connect(cdp_url=...)` | +| `Browser(allowed_domains=...)` | `browser.context.set_domain_policy(allowed_domains=...)` | | `Browser(storage_state=...)` | Cookie API, or a Browserbase context | -| `@sandbox(...)` | `browserbase.launch({ apiKey })` | +| `@sandbox(...)` | `browserbase.launch(api_key=...)` | | `calculate_cost=True`, `history.usage` | `await stagehand.metrics()` | ## Troubleshooting From c7ab9d4554c14e53b9d248b76c58e58552f44765 Mon Sep 17 00:00:00 2001 From: bb Date: Thu, 20 Aug 2026 23:27:07 +0000 Subject: [PATCH 14/14] docs(v4): fix set_domain_policy signature in Browser Use guide --- packages/docs/v4/migrations/browser-use.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/docs/v4/migrations/browser-use.mdx b/packages/docs/v4/migrations/browser-use.mdx index 8132885b4..15de00336 100644 --- a/packages/docs/v4/migrations/browser-use.mdx +++ b/packages/docs/v4/migrations/browser-use.mdx @@ -352,7 +352,7 @@ Browser Use's `@tools.action(...)` / `@controller.action(...)` decorators regist | `Browser(headless=False)` | `local_browser.launch(headless=False)` | | `Browser(cdp_url="http://localhost:9222")` | `local_browser.connect(cdp_url="http://localhost:9222")` | | `Browser(proxy=ProxySettings(...))` | `proxy` on `local_browser.launch()`, or Browserbase proxies | -| `Browser(allowed_domains=[...])` / `prohibited_domains=[...]` | `browser.context.set_domain_policy(allowed_domains=..., blocked_domains=...)` | +| `Browser(allowed_domains=[...])` / `prohibited_domains=[...]` | `browser.context.set_domain_policy(DomainPolicy(allowed_domains=..., blocked_domains=...))` | | `Browser(storage_state="auth.json")` | Cookie API on `browser.context`, or a [Browserbase context](/v4/best-practices/user-data) | | `Browser(user_data_dir=...)` | `user_data_dir` on `local_browser.launch()` | | `Browser(accept_downloads=True, downloads_path=...)` | `accept_downloads` and `downloads_path` on `local_browser.launch()` | @@ -383,7 +383,7 @@ Browser Use's `@sandbox` decorator runs the agent next to the browser on cloud i | `generate_gif=True` | Browserbase [session recording](/v4/configuration/observability) | | `Browser(...)` / `BrowserSession(...)` | `local_browser.launch()` or `browserbase.launch()` | | `Browser(cdp_url=...)` | `local_browser.connect(cdp_url=...)` | -| `Browser(allowed_domains=...)` | `browser.context.set_domain_policy(allowed_domains=...)` | +| `Browser(allowed_domains=...)` | `browser.context.set_domain_policy(DomainPolicy(allowed_domains=...))` | | `Browser(storage_state=...)` | Cookie API, or a Browserbase context | | `@sandbox(...)` | `browserbase.launch(api_key=...)` | | `calculate_cost=True`, `history.usage` | `await stagehand.metrics()` |