Skip to main content
Stagehand v4 drives Chromium over the Chrome DevTools Protocol and has no Playwright dependency, so you can’t hand a Playwright Page to act(). There is no interop; moving a flow to Stagehand means porting it. The deterministic API is familiar but smaller. page.goto(), page.locator(), locator.fill(), and page.screenshot() behave the way you expect, but auto-waiting, getBy* locators, expect(), and route interception don’t exist. In exchange you get act(), extract(), and observe(): natural-language steps that survive the UI changes that break selectors, plus server-side caching that removes inference from a flow once it’s stable.
This guide is TypeScript throughout, because that’s where most Playwright code lives. Stagehand behaves the same way in Python and Go; the SDK reference carries each language’s naming.

What changed

What Stagehand does and doesn’t cover, and what the smallest script looks like on both sides.

Stagehand is not a test framework

Playwright is two things in one install: the library that automates a browser, and @playwright/test, the runner layered on top of it. Stagehand covers the first and has no counterpart to the second, so there are no fixtures, no expect(), no retries, no HTML reporter, no codegen, and no trace viewer. Keep your existing runner if it’s a general one such as Vitest or Jest, and call Stagehand inside it. Tests that depend on @playwright/test fixtures need porting, because those fixtures hand you a Playwright page.

Hello world, side by side

The smallest possible script shows the shape of the port:
  • A browser factory replaces chromium.launch(), and Stagehand.create() attaches the runtime to it.
  • There’s one context per browser, reached at browser.context. browser.newContext() has no equivalent.
  • page.click(selector) is now page.locator(selector).click(). In v4, page.click(x, y) clicks page coordinates.
page.click(), page.hover(), and page.type() all changed meaning. They take coordinates or raw text now, not selectors, so the compiler catches the mistake. Route every selector through page.locator().

Port a script

The order to work in, and a worked example end to end. Most of the work is mechanical, so consider handing it to a coding assistant with the porting rules before doing it by hand.
  1. Port one script end to end, keeping selectors exactly as they are. Get it launching, navigating, and closing on v4 first.
  2. Add explicit waits wherever you relied on Playwright’s auto-waiting.
  3. Replace getBy* locators and assertions, which have no direct equivalent.
  4. Swap the selectors that break most often for act() and extract().
  5. Turn on cache once the flow is stable.

A complete port

A Playwright script that opens Hacker News, switches to the newest stories, and reads the top five titles:
The v4 port keeps the stable selector and spends a model call only where Playwright’s getByRole() did the work:

API differences

One section per area of the API. Skip to the one you’re porting.

Launching and connecting

chromium.launch() becomes a browser factory, and Stagehand.create() attaches to what the factory returns:
localBrowser.launch() accepts the launch and context options you already use: headless, args, executablePath, userDataDir, proxy, locale, viewport, deviceScaleFactor, hasTouch, ignoreHTTPSErrors, downloadsPath, and acceptDownloads. For a hosted browser, use browserbase.launch({ apiKey }). That’s what enables server-side caching and the Model Gateway. To attach to a browser that’s already running, use localBrowser.connect({ cdpUrl }) or browserbase.connect({ apiKey, sessionId }). Close both handles. stagehand.close() releases the Stagehand runtime and leaves the browser running, so call browser.close() yourself. See browser configuration.
Chromium only. Stagehand has no Firefox or WebKit support, and no bundled browser download step. A local run uses the Chrome you already have installed, and a Browserbase run needs nothing installed at all.

Contexts and pages

A Stagehand browser has exactly one context, so there’s no isolation-per-context pattern and no storageState handoff between them. Run separate browsers when you need separate profiles.
newPage() takes an optional URL, which saves a goto(). The rest of the surface maps closely: act(), extract(), and observe() run against the active page unless you pass one: stagehand.act("...", { page }). See using multiple tabs.

Locators

page.locator() takes CSS, XPath (xpath= or a leading /), or text=. CSS selectors pierce shadow DOM, including closed roots, and >> hops through iframes, so page.locator("iframe#checkout >> button.submit") reaches the button inside the frame. That replaces frameLocator(). The getBy* family has no equivalent. In order of preference: Rewrite as CSS, when the app gives you something stable:
Use text=, which matches a case-insensitive substring of the element’s text:
Ask a model, when the original describes the target by role or context rather than markup. observe() returns candidate actions with real selectors attached, so you can inspect one, then drive it deterministically:
Passing that action straight to act() replays it with no further inference. See observe use cases. Rebuild it, when you want the original semantics with no model in the loop. Role, accessible name, and label lookups are DOM logic, and Rebuilding the missing APIs implements them. Chaining and filtering are gone: no locator.locator(), filter(), and(), or or(). Write one selector, narrow with observe(), or filter in the page. first() and nth() work; build last() and all() from count():
For a list you’re going to read anyway, extract() with an array schema replaces that loop and one round trip per row.

Waiting and timeouts

Waiting is where most ported scripts start flaking. Playwright retries every action until the element is attached, visible, stable, and enabled. Stagehand resolves the selector once and throws Could not find an element for the given xPath(s) if it isn’t there yet. Navigation defaults differ, which is easy to miss because nothing errors. Playwright’s goto() waits for load; v4’s waits for domcontentloaded. A script that relied on subresources being ready needs the state spelled out, and waitUntil: "commit" has no equivalent, since v4 accepts load, domcontentloaded, and networkidle only.
Wait first, then act:
waitForSelector() returns a boolean rather than an element handle, so branch on it instead of catching. waitForLoadState() and waitForTimeout() work as you’d expect. Actions do scroll their target into view, so scrollIntoViewIfNeeded() has no port. locator.scrollTo(percent) is a different call: it scrolls content inside a scrollable element. A single wait isn’t the same as Playwright’s per-action retry. When a step races a re-render, use the retry loop instead. There are no default-timeout setters. page.setDefaultTimeout() and setDefaultNavigationTimeout() have no equivalent, so pass timeout per call: on goto(), waitForSelector(), waitForLoadState(), and on act(), observe(), and extract(). domSettleTimeoutMs on Stagehand.create() sets how long the runtime waits for the DOM to settle. Event-based waits have no equivalent: no waitForResponse(), waitForRequest(), waitForEvent(), or waitForURL(). page.on() supports "console" only. Poll page.url() for navigation, or wait for something that only exists on the destination page. Where a wait condition is hard to express as a selector, act() absorbs it. The model reads the page when the call runs, so it sees whatever has rendered by then.

Assertions

There’s no expect() and no web-first assertion retry. Combine an explicit wait with your runner’s assertions:
Locator state reads that survive the port: textContent(), innerText(), innerHtml(), inputValue(), isChecked(), isVisible(), and count(). For getAttribute(), isEnabled(), isEditable(), or a bounding box, drop to page.evaluate(). locator.centroid() gives you the element’s center point. For assertions about what a page says rather than what it contains, extract() with a schema is usually the shorter path:

Network, storage, and files

Route interception has no equivalent. page.route(), context.route(), and request mocking don’t exist. The closest control is a domain allowlist or blocklist on the context:
storageState() has no equivalent either. Persist auth with the cookie API, or with a Browserbase context for full profile reuse. See user data.
Dialogs have no handler. There’s no page.on("dialog") and no dialog.accept(), so replace those flows by stubbing the dialog functions before the page loads:
locator.setInputFiles() ports directly, and also accepts in-memory payloads. The SDK serializes each file in memory and caps it at 50 MiB. Stagehand configures downloads at launch rather than exposing them as events. Pass acceptDownloads and downloadsPath to localBrowser.launch(), and note that Stagehand requires downloadsPath whenever acceptDownloads is true. Stagehand emits no download event and provides no download.saveAs(), so watch the directory yourself instead of awaiting the download.

Debugging

page.pause(), the Playwright Inspector, the trace viewer, page.video(), and npx playwright codegen are all Playwright test tooling, so none of them come along. On Browserbase, the session inspector gives you live view, a full recording with frame-by-frame playback, and network detail. Locally, logging: { level: "debug" } and locator.highlight() cover most of the gap. stagehand.metrics() reports token usage and inference timing per method. See observability.

Rebuilding the missing APIs

Most of what Playwright gives you and v4 doesn’t is DOM logic: getByRole() reads roles and accessible names, filter() compares text, and auto-waiting is a polling loop. All of that runs in the page, so page.evaluate() plus page.locator() is enough to rebuild it. These are the implementations Browserbase uses for its own agent integrations.

The tag-and-locate pattern

One obstacle shapes every recipe here: page.evaluate() returns JSON, so it can’t hand an element back. Work around it by stamping matches with a temporary attribute inside the page, then addressing them with an ordinary locator. That gives you an escape hatch for any query you can express as JavaScript. Here’s getByRole():
Worth knowing about the pattern:
  • Tag immediately before you act, and clear afterwards. A tag written before a re-render points at a detached node.
  • The role map above covers the tags that carry an implicit role in practice. option, list, listitem, table, row, columnheader, cell, navigation, main, form, slider, spinbutton, and searchbox follow the same shape if you need them.
  • Accessible name precedence is aria-label, then the associated label’s text, then alt on an image, then value on an <input> of type button, submit, or reset, then textContent, then title. The snippet stops at textContent, which covers most targets. Add the rest when a page needs it. Playwright’s name option is a case-insensitive substring match unless you pass exact: true.
  • document.querySelectorAll() stops at the main document. To reach shadow DOM, recurse through element.shadowRoot, which exposes open roots only. page.locator() pierces both open and closed roots, so prefer a plain selector when the target sits inside one.

getByText and the smallest-match rule

page.locator("text=Add to cart") matches every element whose text contains the string, including wrappers, so it can resolve a <div> around the button rather than the button. Playwright targets the smallest match. That rule is one line: keep an element only when no child also matches.

filter() and strict mode

filter({ hasText }) is a loop over count() and nth(). Playwright normalizes whitespace and ignores case, so do the same or the port will miss rows:
Strict mode is worth rebuilding too, and it’s the difference most likely to hide a bug. Playwright refuses to act when a locator matches more than one element. v4 acts on the first match, so a selector that quietly went ambiguous keeps working on the wrong element:

Auto-waiting and retries

Playwright retries an action until the element is actionable, which is why ported scripts flake without explicit waits. The loop is short: poll until the element is present and visible, act, and retry if it detached between the check and the action.
Both halves of the error handling matter. Without the RETRYABLE test, the loop swallows assertion and logic failures until the deadline, so a genuine bug surfaces 30 seconds later as a timeout. Without lastError, a failure that keeps recurring loses its message and locator detail to the generic timeout. Widen the pattern as you meet new transient messages, and keep rethrowing everything else immediately. The same loop replaces locator.waitFor({ state }) when the query isn’t a selector you can hand to page.waitForSelector(). Read count() for attached and detached, and count() plus isVisible() for visible and hidden.
Don’t wrap act() in a retry loop. A failed act() may already have clicked or submitted before the error surfaced. Retry observe(), which only plans, then pass its action to act() once.

Element reads through evaluate

Locator covers text, value, checked state, visibility, and count. Everything else is a one-line evaluate():

The remaining equivalences

Each of these is a direct substitution: What doesn’t rebuild this way is anything that needs the network layer rather than the DOM: page.route(), request mocking, waitForResponse(), waitForRequest(), and the request, response, and download page events. The SDK exposes no CDP escape hatch and page.on() carries "console" only, so those stay unported.

What to convert after the port

A one-for-one port gets the script running on v4. Once it’s green, convert the steps that break most often:
  • Selectors that churn. Marketing pages, third-party checkouts, and A/B tested UI. Replace with act(), and turn on selfHeal so a recorded action re-infers when its selector breaks.
  • Scraping loops. A count() and nth() loop over rows becomes one extract() call with an array schema, typed and validated.
  • Flows you run repeatedly. Set cache: true on Stagehand.create(). Browserbase caches act(), observe(), and extract() results keyed on the instruction, page content, and options, so a stable flow stops paying for inference. Read metadata.cache.status to confirm hits.
  • Chatty stretches of deterministic code. Every SDK call is a round trip to the browser, which a Playwright script full of locator reads will feel. experimentalBatch() runs a callback next to the page so those calls stay local. The callback can’t capture variables from your process, so pass what it needs as input. It’s experimental and can change in patch releases.
Reach for page.locator() everywhere a selector is stable. A model call per step costs latency and money that a CSS selector doesn’t. See cost optimization.

Quick reference

“No equivalent” here means no built-in method. Rebuilding the missing APIs implements most of them in a few lines.

Setup and lifecycle

Locators

Actions

Waiting and assertions

Reading state

Storage, network, and tooling

Agent instructions

Most of this guide is a mapping table, so hand it to a coding assistant rather than retyping the rules. The rules below carry the whole mapping, so pair them with the prompt and the assistant has everything the page covers. Set up AI rules first. Without them, assistants fall back on the v2 and v3 patterns in their training data, and on Playwright APIs that v4 doesn’t have.

Kick off the port

Porting rules

Paste this block into your rules file (AGENTS.md, CLAUDE.md, or .cursor/rules) for the length of the migration. It’s the mapping an assistant gets wrong most often, stated as rules rather than prose:

Troubleshooting

Could not find an element for the given xPath(s). The element wasn’t in the DOM when the locator resolved. Stagehand doesn’t auto-wait, so add page.waitForSelector() before the action. page.click() clicks the wrong thing, or nothing. You passed a selector to the coordinate API. Use page.locator(selector).click(). Property 'getByRole' does not exist on type 'Page'. The getBy* family has no equivalent. Rewrite as CSS, use text=, or resolve the element with observe(). A Playwright page won’t type-check as act()’s page option. v4 has no page interop, so that option only accepts a page from browser.context. Property 'length' does not exist on an observe() result. Every primitive returns { data, metadata }. Read .data. Assertions pass locally and fail in CI. Playwright’s assertion retry is gone. An innerText() read that follows a click needs a waitForSelector() between them. Your generated script uses Playwright APIs on a Stagehand page. The assistant is pattern-matching on Playwright in its training data. Install the rule files from AI rules.

Next steps

Page reference

The full deterministic surface

Observe

Get real selectors from a description

Extract

Replace scraping loops with typed data

Caching

Cut inference out of a stable flow