Chapter 05 · Browser Use To Stagehand
Subchapter 5.4
references/prompt.mdMarkdown9 KBView on GitHub
A copy-pasteable prompt that turns a browser-use (Python) script into a Stagehand v3 (TypeScript) script on Browserbase — choosing the right level of determinism per step instead of producing a one-to-one agentic copy.
How to use
act(...) prompts to the actual on-page labels, and confirm any flagged items.Prefer it as a one-command tool inside Claude Code? The same logic ships as the
/browser-use-to-stagehandskill. This prompt is the universal, tool-agnostic form.
You are migrating a browser-use (Python) browser-automation script to Stagehand v3 (TypeScript) running on Browserbase. Produce idiomatic, runnable Stagehand v3 code plus a migration summary. This is a refactor with judgment, not a line-by-line transpile.
browser-use is agentic-by-default: an LLM decides every action on every run. Stagehand lets you choose how much AI to use at each step. A good migration replaces opaque agent loops with an inspectable, mostly-deterministic pipeline — using AI only where the page is genuinely unpredictable. The payoff is determinism, lower cost, and debuggability.
Most training data and blog posts show Stagehand v2. Use v3:
import "dotenv/config";
import { Stagehand } from "@browserbasehq/stagehand";
import { z } from "zod";
const stagehand = new Stagehand({ env: "BROWSERBASE", model: "anthropic/claude-sonnet-4-6" });
await stagehand.init();
try {
// ... work ...
} finally {
await stagehand.close();
}const page = stagehand.context.pages()[0]; — not stagehand.page.stagehand.act(...), stagehand.extract(...),
stagehand.observe(...) — not page.act(...)."provider/model" strings: e.g. "anthropic/claude-sonnet-4-6", "openai/gpt-5",
"google/gemini-2.5-flash". (Pin to whatever the team uses; ids move fast.)extract uses a zod schema; v3 supports a top-level z.array(...) with no wrapper object.variables — the %token% is sent to the LLM, the real value is substituted
locally and never leaves the machine:
await stagehand.act("type %username% into the email field", {
variables: { username: process.env.APP_USER! },
});env: "BROWSERBASE"; show env: "LOCAL" (with localBrowserLaunchOptions) only as
the dev option.selfHeal: true, cacheDir, serverCache (on by default under
BROWSERBASE).| Level | Stagehand | Use when |
|---|---|---|
| 1. Autonomous | stagehand.agent().execute("…") | Path is genuinely open-ended/unknown. |
| 2. Per-step AI | act("…"), extract("…", schema) | Step is known but markup varies. |
| 3. Observe → act | const [a] = await stagehand.observe("…"); if (a) await stagehand.act(a); | Known step you want to resolve once and replay (the act(a) call makes no LLM call). |
| 4. Self-heal + cache | selfHeal, cacheDir, serverCache | Production replay that should recover from DOM drift. |
| 5. Navigation (no AI) | page.goto(url), page.url() | Loading a known URL or reading the current location. No LLM call. (Element interactions are Levels 2–4.) |
Decision rule: split each browser-use task="…" string into its implied ordered steps, then
place each on the spectrum. Default to decomposition (levels 2–5) when the flow is known; keep
agent() (level 1) only for genuinely open-ended tasks (tighten it with maxSteps, a systemPrompt,
and output for typed results). Reading data is always extract, never a full agent.
Example: task="Go to the store, search 'wireless mouse', add the cheapest to cart, checkout with my saved card" → page.goto(store) (L5) → act("search 'wireless mouse'") (L2) → extract the
prices + pick the min in code + act("add to cart") (L2) → checkout via a Browserbase Context
so auth is already present (L3/4).
| browser-use | Stagehand v3 / Browserbase |
|---|---|
Agent(task=…) + agent.run() | Decompose into act/extract/observe when the flow is known; else stagehand.agent().execute(…) |
llm=ChatAnthropic(model="claude-sonnet-4-6") | new Stagehand({ model: "anthropic/claude-sonnet-4-6" }) |
llm=ChatOpenAI(model="gpt-5") / ChatGoogle(...) | model: "openai/gpt-5" / "google/gemini-2.5-flash" |
output_model_schema=PydanticModel | stagehand.extract("…", zodSchema) (preferred); or agent().execute({ output: zodObjectSchema }) — needs experimental: true, zod object only (see ⚠️ below) |
history.final_result() / .structured_output | extract(...) return / result.output |
Browser() (local) | new Stagehand({ env: "LOCAL", localBrowserLaunchOptions }) |
Browser(cdp_url=session.connect_url) (Browserbase) | new Stagehand({ env: "BROWSERBASE" }) (Stagehand manages the session) |
sensitive_data={…} | act("…%key%…", { variables: { key } }) |
storage_state / user_data_dir | Browserbase Context: browserbaseSessionCreateParams.browserSettings.context: { id, persist: true } |
| proxies / stealth / captcha / region | browserbaseSessionCreateParams (proxies, browserSettings.advancedStealth, solveCaptchas, region) |
@tools.action (deterministic side-effect) | plain TypeScript |
@tools.action (capability the agent must choose) | stagehand.agent({ tools: { name: tool({ description, inputSchema: z.object({…}), execute }) } }) — tool from the ai package (pin ai@^5); needs experimental: true (see ⚠️ below) |
page_extraction_llm=… | extract("…", schema, { model }) |
planner_llm=… + main llm=… | agent({ model, executionModel }) |
max_steps | agent().execute({ maxSteps }) |
⚠️ Experimental gate: agent
output, customtools, and MCPintegrationseach requireexperimental: trueon theStagehandconstructor (it bypasses the managed API path). For a typed result from an agentic run, prefer running the agent then a separatestagehand.extract(...).
allowed_domains — Stagehand has no domain firewall, and it’s often a security boundary
(it pairs with sensitive_data). Mitigate with a page.url() host check before sensitive
actions, a systemPrompt constraint (for agents), or Browserbase proxy domain rules. Never drop
it silently — flag it as needs-review.max_actions_per_step, use_thinking, flash_mode — no direct equivalent; decomposition
makes steps explicit. For speed, use a fast model (google/gemini-2.5-flash) + decomposition.initial_actions — becomes ordinary code that runs before the first AI call.Browser(config=BrowserConfig(...)), BrowserContext, Controller /
@controller.action. Normalize names first.Browser(browser_profile=BrowserProfile(...)), Tools() / @tools.action;
Browser ≡ BrowserSession. (Most scripts.)browser_use.beta. Same public surface.package.json (deps: @browserbasehq/stagehand,
zod, dotenv; add ai only if a custom action maps to an agent tool) and the required .env
keys (BROWSERBASE_API_KEY, BROWSERBASE_PROJECT_ID, the provider key matching the model, plus
any app secrets).allowed_domains guardrails, custom-action logic, placeholder
URLs/labels, anything ambiguous.selfHeal +
caching for production.stagehand.act/extract/observe), page via
stagehand.context.pages()[0].model is a "provider/model" string; the matching provider key is in .env.extract uses a zod schema; secrets use variables + process.env; nothing hardcoded.init() / close() present (close() in a finally).allowed_domains is
not silently dropped.If no script was provided, ask for the browser-use script before proceeding. Now migrate the browser-use script provided by the user.