Stagehand v4: What Actually Happens When You Run It
We installed Stagehand 4.0.0, ran it against a real page, measured every token it spent, and broke it on purpose. This picks up exactly where our architecture breakdown of Stagehand v4 leaves off: no more "what is it," only "what happens when you type pnpm install and start calling it." Every number below comes from a script you can find and rerun, not from the docs. For more hands-on breakdowns like this one, browse our hub of tested AI tools.
How we verified this. Stagehand 4.0.0, Ubuntu 24.04, Node 22, Chrome 151, tested locally and on Browserbase, against 4geeks.com and other live pages, in August 2026. We rechecked model validation on 4.2.1 in October 2026, and the published types for 4.3.0, the current npm release (October 20, 2026), still work the same way; the token, latency and model tables below are still 4.0.0 measurements. Where a finding contradicts our own earlier notes from this same investigation, we say so.
Install: faster than you'd expect, with one sharp edge
pnpm install @browserbasehq/stagehand zod pulls 51 packages in about 4.5 seconds and downloads no browser at all. If you're coming from Playwright, that's the first surprise: v4 drives the Chrome you already have, through CHROME_PATH, not a browser it manages for you.
The one thing that will stop a reader cold: pnpm init writes devEngines.packageManager.version: "^11.21.0" into package.json. corepack refuses that range outright:
Invalid package manager specification in package.json (pnpm@^11.21.0);
expected a semver versioncorepack wants an exact version, and pnpm init doesn't give it one. npm install -g pnpm sidesteps the whole thing.
Two more setup traps worth knowing before you touch code:
- Stagehand does not read your environment variables, despite a provider table in the docs with an "Environment Variable" column that reads like auto-detection. Set
GOOGLE_GENERATIVE_AI_API_KEYand call it a day, and you getModel inference requires a provider API key or a Browserbase session, an error that points at authentication when the real problem is that nothing ever read the variable. - Browserbase signup requires phone number verification. Not a card, but a real barrier worth knowing about before a tutorial sends someone there mid-lesson.
The docs will send you to the wrong page first
When we tested in August, searching for Stagehand's model configuration led first to docs.stagehand.dev/configuration/models, which served the v3 page with 13 providers. The v4 page lists 5. As of October 19, 2026, that unversioned address redirects to the v4 page, so the obvious link is fixed. Search results and old bookmarks can still land you on /v3/ pages, though, so check the version in the URL before you copy a config.
That same stale surface is where the most common first error comes from. The docs' own Model Gateway example writes:
const stagehand = new Stagehand({ env: "BROWSERBASE", model: "openai/gpt-5" });In v4, a bare string for model throws a ZodError: expected object, received string. The real shape is:
model: { modelName: "google/gemini-3-flash-preview", apiKey: "..." }That snippet isn't wrong, exactly. It's valid Python (the Python SDK really does accept a string, with the key as a separate argument), sitting on a TypeScript doc page.
extract() has the same problem, and it's worse because of how the error reads. Every example in circulation, including the official docs, writes:
await stagehand.extract({ instruction: "…", schema: z.object({…}) }); // throwsThe thrown error names instruction: expected string, received object. That reads like your instruction is malformed. It isn't. The real signature is positional:
await stagehand.extract("get the heading", z.object({ heading: z.string() }), { timeout: 30000 });Since 4.2.0, locator methods also take their own optional timeout, so a single slow element can get extra time without stretching every other step.
With no schema at all, extract() returns { extraction: string }, a plain string with no structure. Worth knowing before you assume it failed silently.
A few smaller Playwright habits that break the same way: page.url() is async now (ported code logs a pending Promise instead of a URL), page.on() accepts only "console", and logLevel: "debug" isn't a real option; it's logging: { level, format, onLog }.
What it actually costs, per call
Every inference call ships the page's full accessibility tree to the model. On 4geeks.com's homepage, that tree runs 67,347 characters, roughly 21,000 tokens, before your instruction is even added. That number is the story: cost tracks page size, not what you asked for.
| Method | Prompt tokens | Completion tokens | Time | Model calls |
|---|---|---|---|---|
observe() | 20,956 | 38 to 758 | 5 to 8 s | 1 |
act() by instruction | 16,006 | 32 | 9.8 s | 1 |
act() replay (cached action) | 0 | 0 | 0 s | 0 |
extract() | 16,419 | 104 | 13.3 s | 2 |
act() self-heal | 21,410 | 44 | 1.4 s | 1 |
That extract() row has a hidden cost nothing surfaces on its own: it makes two model calls, not one. The first extracts against your schema. The second checks its own work against a built-in schema, { progress: string, completed: boolean }, deciding whether the goal is "now accomplished." metadata.usage reports both calls as if they were one, so a budget built on "one call per extract()" is wrong from the first run. An observe() + extract() sequence is three requests, not two, and that matters most on a free tier that counts requests.
The one setting that actually moves the needle: locator
Scoping a call to a subtree instead of handing over the whole page is the single biggest lever in the library, and it doesn't show up in the quickstart.
| Variant | Input tokens | Change |
|---|---|---|
| Whole page | 15,883 to 20,957 | baseline |
locator: page.locator("header") | 481 to 950 | −94% to −98% |
locator: page.locator("nav") | 471 to 941 | −94% to −98% |
ignoreLocators: [footer] | 12,482 to 15,746 | −1% to −40%, page-dependent |
Scoping in beats excluding out. ignoreLocators only helps as much as the excluded subtree happens to be large, which varies by page; locator gives you a predictable, near-total cut every time.
One API trap: locator takes an actual Locator instance, the object page.locator(...) returns. Passing { selector: "header" } throws. That object shape is not the wire format.
One tradeoff worth stating plainly: locator switches server-side caching to DISABLED for that call. The 94% token saving and a cache hit are mutually exclusive on the same request; you pick one per call, not both.
The cache trap: it's a counter, not a similarity score
cache.threshold defaults to 10, and it's a repetition count: the identical instruction has to run against the identical page ten times before anything is ever served from cache. That's why { threshold: 0.8 } throws expected int, received number, not a similarity failure; the field was never built to take a fraction. Most people reading "threshold" assume the second thing.
Set it to 1 and a hit costs 0 tokens and 0 milliseconds, confirmed with tokensSaved in the response. Entries persist across sessions and are account-scoped, and the model is deliberately excluded from the cache key, so you can warm a cache with a cheap model and run an expensive one against it for free later. All of this is Browserbase-only: a local browser accepts cache: true without complaint and every result still comes back { "cache": { "status": "DISABLED" } }.
A caching security note that's easy to miss. %variable% substitution keeps secret values out of the model's prompt entirely, which is the advertised protection. But with server-side caching turned on, the real values still travel to the cache service. Turn caching off for any call that carries credentials.
model: { generate }: the workaround the docs don't mention
model is a union type. One branch is the familiar { modelName, apiKey }, limited to five providers: openai, anthropic, google, groq and cerebras. On 4.0.0 through 4.2.0, modelName was checked against a hardcoded list of exact model names: 147 in 4.0.0, the version we measured, and 148 in 4.1.0 and 4.2.0. In 4.2.1 that list is gone: any id after one of those five prefixes passes. The other branch is { generate }: you hand Stagehand a function, and it calls that instead of any built-in provider.
That function runs in Node, not inside the extension, confirmed by logging the process ID from inside it. Three consequences:
- Any provider becomes usable: OpenRouter, Ollama, Bedrock, a local model, anything reachable over HTTP. The five-provider limit stops being a constraint.
- You can log exactly what Stagehand sends before it goes anywhere.
- You control retries, timeouts, and routing yourself.
That last point turned out to be the most useful debugging tool in this whole investigation: a stub generate that records the prompt and throws, at zero cost, is how the hidden second extract() call above was actually found.
The model name check changed in 4.2.1, and the trap moved with it. On 4.0.0, near-miss names failed right away: groq/llama-3.3-70b was rejected by Stagehand's own validation, because the real entry was groq/llama-3.3-70b-versatile. In 4.2.1, the published schema accepts anything shaped like groq/ plus an id, so that same typo sails through Stagehand and only fails when Groq rejects it. You get a provider error at the first model call instead of a clear validation error at startup. Copy model ids straight from the provider's model list.
Two things didn't change. model: "auto", the value the Model Gateway docs use, still isn't valid in modelName, because it has no provider prefix. And in August, two models named in Stagehand's own docs, groq/llama-3.3-70b-versatile and groq/llama-3.1-8b-instant, no longer existed on a live Groq account. Docs examples can go stale, so treat any model name in them as a starting point, not a guarantee.
We ran ten models against the same page. The cheap one lost.
The expectation going in was that small, cheap models would struggle on a real 21,000-token accessibility tree. They didn't. Every model tested, from claude-haiku-4-5 to gpt-5-nano to two different Gemini variants, returned the identical, correct xpath. Model choice changed speed and verbosity. It did not change correctness.
| Model | Input tokens | Output tokens | Time |
|---|---|---|---|
anthropic/claude-haiku-4-5 | n/a | 41 | 1,860 ms |
openai/gpt-5-mini | 14,849 | 243 | 3,565 ms |
openai/gpt-5-nano | 14,821 | 856 | 14,030 ms |
google/gemini-3.5-flash | 17,301 | 165 | 4,807 ms |
google/gemini-3-flash-preview | 17,325 | 231 | 6,647 ms |
gpt-5-nano is the cheapest model per token in that table and the worst deal in it: 20x the output tokens and 7.6x the latency of claude-haiku-4-5, reasoning its way through a one-line lookup, for the exact same answer. Cheapest per token is not cheapest per task, and on a job this repetitive, verbosity is the whole cost.
If budget is the real constraint, the best combination measured wasn't a frontier model at all: Groq, scoped with locator. Groq's free tier caps at 8,000 tokens per minute, which can't fit a single unscoped ~21,000-token call, full stop; that isn't a rate limit you wait out, the request can never fit. Scope the same call to page.locator("header") and it drops to 770 tokens, and Groq's gpt-oss-20b answers correctly in 613 ms. Priced at roughly $0.075 per million input tokens, a scoped observe() on Groq costs about $0.00015, on the order of 100x cheaper than the same call unscoped on a frontier model. Cerebras, which serves the identical gpt-oss-120b model, is 2.3x more expensive on input and, worse, the advertised "$5 free credit" account showed a $0.00 balance with every call rejected as payment_required. Nothing was usable there for free.
Where it fails, and how loud it is about it
Cross-origin iframes are invisible, and say nothing about it
Shadow DOM works, including closed-mode roots. Same-origin iframes work; the xpath walks straight through the iframe[1] node and act() clicks inside it correctly. A cross-origin iframe is a different story: it never appears in the accessibility snapshot at all. observe() returns zero results, no error, no warning.
That rules out, silently, exactly the elements people most want to automate: Stripe checkout fields, embedded OAuth flows, hCaptcha widgets, third-party support chat. If a page has one of those and your observe() comes back empty, that's not a bug in your instruction.
act() fails quietly too
A stale selector doesn't throw. act() returns success: false, and code that doesn't check for it keeps running as if the click happened. selfHeal fixes this by re-inferring the action against a fresh accessibility tree, at a real cost (21,410 tokens in one measured run), but it's off by default. Absolute xpaths go stale across a single page reload, so any strategy built around cached, replayed actions needs selfHeal: true explicitly turned on.
headless: true cannot work, structurally
This isn't a bug that gets patched. Stagehand v4's engine runs as a Chrome extension, and Chrome extensions do not load in headless mode. There's no workaround. The failure that surfaces is a bare Failed to fetch, an error that mentions neither headless mode, nor extensions, nor the browser at all, so it reads like a network problem. For CI or a server, the answer is Browserbase, not a headless flag.
One honest caveat for anyone running locally on a heavy page: in this testing, roughly one call in five failed with that same Failed to fetch message, at random, on a local browser against a large page. Browserbase never showed this behavior once. Build retries into anything running against a local Chrome; don't assume a single failure means the code is wrong.
Connecting to your own browser installs an extension into it
localBrowser.connect({ cdpUrl }) connects to an already-running Chrome in about 277 milliseconds, and Stagehand.create() then installs its extension into that browser over CDP, using Extensions.loadUnpacked. This is confirmed by inspecting the live CDP target list right after connecting: the extension is there, permanently, in that browser profile.
That means "attach Stagehand to my existing browser" quietly means "add a persistent extension to my existing browser," and that extension has access to whatever is logged in there. A Chrome DevTools port has no authentication of its own, so any local process that can reach it can drive the browser. None of this carries a warning in the v4 docs. If you're demoing this, use a throwaway profile, not your daily driver.
Verdict
For anything that has to run unattended on a schedule, in CI, or on a server: Browserbase, not a local browser. Headless is structurally impossible locally, caching and model routing only work through Browserbase, and the local Failed to fetch failure rate makes a local browser a bad bet for anything unattended.
For learning the library, prototyping, or a script you'll watch run: local Chrome works fine, and it's free. Just scope every call with locator from the start (it's not an optimization to add later, it's close to a 95% cost cut with no downside beyond losing the cache on that call), turn on selfHeal if you're replaying cached actions, and build in a retry for the local-only Failed to fetch flake.
Don't assume the cheapest listed model is the cheapest run. Test one call before committing a pipeline to a "budget" model; a verbose one can cost more in output tokens and wall-clock time than a model that costs more per token and says less.
And don't extend it past what it does. It doesn't see into cross-origin iframes. headless: true doesn't work and never will on this architecture. Attaching to a real browser installs something into it permanently. None of those are edge cases you'll avoid by being careful; they're structural, and worth knowing on day one instead of finding out mid-project.
What this means if you're learning to build these systems
Every number in this article came from running the thing and reading what came back, not from trusting a docs page or a launch tweet. That's the actual skill: instrumenting a black box cheaply (a stub function that records and throws costs nothing and tells you everything), then checking a specific, falsifiable claim against a specific, reproducible run. It's the same posture that matters once you're the one deciding whether a library, a model, or a vendor's benchmark is safe to build on. If you want to go deeper into agent architecture, evaluation, and the engineering judgment behind decisions like these, that's the core of 4Geeks Academy's AI Engineering program.
