4Geeks chosen to deliver AI education in the Bahamas alongside Harvard, Oxford, and Columbia.See more
Image: 4geeks logo big
SIGN IN
ai-tools

Stagehand v4: What Actually Happens When You Run It

We installed Stagehand v4, measured every token it spends, and broke it on purpose. Real install steps, real costs per method, and the traps the docs don't mention.
Authors:4Geeks Academy14 min read

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.

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. 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 version

corepack 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_KEY and call it a day, and you get Model 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

Search for Stagehand's model configuration and the top result is docs.stagehand.dev/configuration/models, which serves the v3 page: 13 providers listed. The current page, at /v4/, lists 5. A reader who follows the obvious link gets a provider list that no longer exists.

That same stale surface is where the most common first error comes from. The docs' own Model Gateway example writes:

typescript
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:

typescript
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:

javascript
await stagehand.extract({ instruction: "…", schema: z.object({…}) }); // throws

The thrown error names instruction: expected string, received object. That reads like your instruction is malformed. It isn't. The real signature is positional:

javascript
await stagehand.extract("get the heading", z.object({ heading: z.string() }), { timeout: 30000 });

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 Promise { <pending> } 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.

MethodPrompt tokensCompletion tokensTimeModel calls
observe()20,95638–7585–8 s1
act() by instruction16,006329.8 s1
act() replay (cached action)000 s0
extract()16,41910413.3 s2
act() self-heal21,410441.4 s1

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.

VariantInput tokensChange
Whole page15,883–20,957baseline
locator: page.locator("header")481–950−94% to −98%
locator: page.locator("nav")471–941−94% to −98%
ignoreLocators: [footer]12,482–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 }, validated against a hardcoded list of 147 exact model names across five providers (openai, anthropic, google, groq, cerebras: full dump available on request). 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 147-name allowlist 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 allowlist itself is worth a quick look before you pick a model. Near-miss names fail outright: groq/llama-3.3-70b is rejected because the real entry is groq/llama-3.3-70b-versatile. model: "auto", the value the Model Gateway docs use, isn't valid here at all. And two models named directly in Stagehand's own docs, groq/llama-3.3-70b-versatile and groq/llama-3.1-8b-instant, no longer exist on a live Groq account; the allowlist is a snapshot that's drifted since it was written.


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.

ModelInput tokensOutput tokensTime
anthropic/claude-haiku-4-5n/a411,860 ms
openai/gpt-5-mini14,8492433,565 ms
openai/gpt-5-nano14,82185614,030 ms
google/gemini-3.5-flash17,3011654,807 ms
google/gemini-3-flash-preview17,3252316,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.

Become an AI Engineer

Reading a vendor's benchmark with a magnifying glass and knowing what a library actually costs before you ship it: that's the judgment 4Geeks' flagship program is built to train.

Frequently Asked Questions