# Fetch
Source: https://docs.stagehand.dev/v4/add-ons/fetch
Turn any webpage into agent context, without launching a browser
## What is `fetch()`?
Sometimes an agent doesn't need to *use* a page, it needs to *read* one. `browserbase.fetch()` takes a URL and returns its content as markdown, structured JSON, or the raw body.
```typescript theme={null}
import { browserbase } from "@browserbasehq/stagehand";
const fetchResult = await browserbase.fetch({
apiKey: process.env.BROWSERBASE_API_KEY,
url: "https://example.com",
format: "markdown",
});
console.log(fetchResult.content);
```
```python theme={null}
import os
from stagehand import browserbase
fetch_result = await browserbase.fetch(
api_key=os.environ["BROWSERBASE_API_KEY"],
url="https://example.com",
format="markdown",
)
print(fetch_result.content)
```
```go theme={null}
fetchResult, err := stagehand.FetchBrowserbase(ctx, stagehand.BrowserbaseFetchOptions{
APIKey: os.Getenv("BROWSERBASE_API_KEY"),
URL: "https://example.com",
Format: stagehand.BrowserbaseFetchFormatMarkdown,
})
if err != nil {
return err
}
content, ok := fetchResult.Content.AsString()
if !ok {
return errors.New("fetch returned non-string content")
}
fmt.Println(content)
```
`fetch()` is a Browserbase cloud feature. The request runs on Browserbase infrastructure and needs a Browserbase API key, so local browsers have no equivalent.
## Why use `fetch()`?
Every call retrieves the live page, not a copy from a crawl index, so what the model reads is what the site serves right now.
No browser boot, no render, and no wait for the DOM to settle. Fast enough to sit inside a latency-sensitive agent loop.
Markdown and JSON strip the boilerplate and layout, so the model spends its context on content instead of markup.
Pages that block datacenter traffic are reachable by routing the request through Browserbase's proxy network.
## Setup
`fetch()` ships with the Stagehand SDK. The only requirement is a [Browserbase API key](https://www.browserbase.com/overview):
```bash theme={null}
export BROWSERBASE_API_KEY="bb_live_..."
```
```typescript theme={null}
import "dotenv/config";
import { browserbase } from "@browserbasehq/stagehand";
const fetchResult = await browserbase.fetch({
apiKey: process.env.BROWSERBASE_API_KEY,
url: "https://docs.stagehand.dev/v4/first-steps/introduction",
format: "markdown",
});
console.log(fetchResult.statusCode, fetchResult.contentType);
console.log(fetchResult.content);
```
```python theme={null}
import asyncio
import os
from stagehand import browserbase
async def main() -> None:
fetch_result = await browserbase.fetch(
api_key=os.environ["BROWSERBASE_API_KEY"],
url="https://docs.stagehand.dev/v4/first-steps/introduction",
format="markdown",
)
print(fetch_result.status_code, fetch_result.content_type)
print(fetch_result.content)
if __name__ == "__main__":
asyncio.run(main())
```
```go theme={null}
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
stagehand "github.com/browserbase/stagehand/packages/sdk-go/v4"
)
func main() {
if err := run(context.Background()); err != nil {
log.Fatal(err)
}
}
func run(ctx context.Context) error {
apiKey := os.Getenv("BROWSERBASE_API_KEY")
if apiKey == "" {
return errors.New("BROWSERBASE_API_KEY is required")
}
fetchResult, err := stagehand.FetchBrowserbase(ctx, stagehand.BrowserbaseFetchOptions{
APIKey: apiKey,
URL: "https://docs.stagehand.dev/v4/first-steps/introduction",
Format: stagehand.BrowserbaseFetchFormatMarkdown,
})
if err != nil {
return err
}
content, ok := fetchResult.Content.AsString()
if !ok {
return errors.New("fetch returned non-string content")
}
fmt.Println(fetchResult.StatusCode, fetchResult.ContentType)
fmt.Println(content)
return nil
}
```
## Output formats
`format` decides what lands in `content`. Pick the cheapest representation that still answers your question.
Markdown is the default choice for agent context: headings, links, and text survive, while navigation chrome and layout markup don't.
```typescript theme={null}
const { content } = await browserbase.fetch({
apiKey: process.env.BROWSERBASE_API_KEY,
url: "https://example.com/blog/post",
format: "markdown",
});
```
```python theme={null}
result = await browserbase.fetch(
api_key=os.environ["BROWSERBASE_API_KEY"],
url="https://example.com/blog/post",
format="markdown",
)
content = result.content
```
```go theme={null}
result, err := stagehand.FetchBrowserbase(ctx, stagehand.BrowserbaseFetchOptions{
APIKey: os.Getenv("BROWSERBASE_API_KEY"),
URL: "https://example.com/blog/post",
Format: stagehand.BrowserbaseFetchFormatMarkdown,
})
if err != nil {
return err
}
content, _ := result.Content.AsString()
```
`content` is a string.
Pass a JSON Schema and get back an object that matches it. The extraction happens server-side, so no model call of your own is involved.
```typescript theme={null}
const { content } = await browserbase.fetch({
apiKey: process.env.BROWSERBASE_API_KEY,
url: "https://example.com/products/mouse",
format: "json",
schema: {
type: "object",
properties: {
name: { type: "string" },
price: { type: "number" },
inStock: { type: "boolean" },
},
required: ["name", "price"],
},
});
// content is an object, not a string
console.log(content);
```
```python theme={null}
result = await browserbase.fetch(
api_key=os.environ["BROWSERBASE_API_KEY"],
url="https://example.com/products/mouse",
format="json",
schema={
"type": "object",
"properties": {
"name": {"type": "string"},
"price": {"type": "number"},
"inStock": {"type": "boolean"},
},
"required": ["name", "price"],
},
)
# result.content is a dict, not a string
print(result.content)
```
```go theme={null}
result, err := stagehand.FetchBrowserbase(ctx, stagehand.BrowserbaseFetchOptions{
APIKey: os.Getenv("BROWSERBASE_API_KEY"),
URL: "https://example.com/products/mouse",
Format: stagehand.BrowserbaseFetchFormatJSON,
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string"},
"price": map[string]any{"type": "number"},
"inStock": map[string]any{"type": "boolean"},
},
"required": []string{"name", "price"},
},
})
if err != nil {
return err
}
// Object content, not string content
extracted, ok := result.Content.AsObject()
if !ok {
return errors.New("fetch returned non-object content")
}
fmt.Println(extracted)
```
`schema` and `format: "json"` go together: passing a schema with any other format, or asking for `json` without one, fails validation before the request is sent.
The upstream response body, untouched. Use it when you want to parse the page yourself, or when the response isn't HTML at all: a JSON API, an RSS feed, or a CSV export.
```typescript theme={null}
const { content, contentType } = await browserbase.fetch({
apiKey: process.env.BROWSERBASE_API_KEY,
url: "https://example.com/feed.xml",
format: "raw",
});
```
```python theme={null}
result = await browserbase.fetch(
api_key=os.environ["BROWSERBASE_API_KEY"],
url="https://example.com/feed.xml",
format="raw",
)
```
```go theme={null}
result, err := stagehand.FetchBrowserbase(ctx, stagehand.BrowserbaseFetchOptions{
APIKey: os.Getenv("BROWSERBASE_API_KEY"),
URL: "https://example.com/feed.xml",
Format: stagehand.BrowserbaseFetchFormatRaw,
})
```
`raw` is the default when you omit `format`, and the cheapest of the three. Check `encoding` before treating `content` as text: binary responses come back `base64`.
## Proxies and transport
Some sites refuse datacenter traffic, some answer only after a redirect, and some serve a certificate you'd rather not argue with. Three booleans cover those cases, all off by default.
```typescript theme={null}
const result = await browserbase.fetch({
apiKey: process.env.BROWSERBASE_API_KEY,
url: "https://example.com/protected",
format: "markdown",
proxies: true, // route through Browserbase's proxy network
allowRedirects: true, // follow HTTP redirects
allowInsecureSsl: true, // skip TLS certificate verification
});
```
```python theme={null}
result = await browserbase.fetch(
api_key=os.environ["BROWSERBASE_API_KEY"],
url="https://example.com/protected",
format="markdown",
proxies=True, # route through Browserbase's proxy network
allow_redirects=True, # follow HTTP redirects
allow_insecure_ssl=True, # skip TLS certificate verification
)
```
```go theme={null}
proxies := true
allowRedirects := true
allowInsecureSSL := true
result, err := stagehand.FetchBrowserbase(ctx, stagehand.BrowserbaseFetchOptions{
APIKey: os.Getenv("BROWSERBASE_API_KEY"),
URL: "https://example.com/protected",
Format: stagehand.BrowserbaseFetchFormatMarkdown,
Proxies: &proxies, // route through Browserbase's proxy network
AllowRedirects: &allowRedirects, // follow HTTP redirects
AllowInsecureSSL: &allowInsecureSSL, // skip TLS certificate verification
})
```
Turn `proxies` on only for the requests that need it. Proxied fetches cost more and add latency, so a per-domain fallback beats enabling it globally.
## Response
Every fetch returns the content alongside the transport metadata, so you can tell a real page from a soft 404 or a bot wall before handing anything to a model.
```json theme={null}
{
"id": "fet_01JB8Z3K7Q2M4N",
"content": "# Example Domain\n\nThis domain is for use in illustrative examples...",
"contentType": "text/html; charset=utf-8",
"encoding": "utf-8",
"headers": { "content-type": "text/html; charset=utf-8" },
"statusCode": 200
}
```
Field names follow each language's conventions: `contentType` and `statusCode` in TypeScript, `content_type` and `status_code` in Python, and `ContentType` and `StatusCode` on the Go structs. In Go, `Content` is a union: call `AsString()` for `raw` and `markdown`, and `AsObject()` for `json`.
## API reference
### Parameters
Your Browserbase API key. Fetches are billed to the project that owns the key.
The `http` or `https` URL to fetch. Validated locally before the request is sent.
Which representation to return in `content`. Defaults to `raw`.
JSON Schema describing the object to extract. Required with `format: "json"`, and rejected with any other format.
Route the request through Browserbase's proxy network. Defaults to `false`.
Follow HTTP redirects. Defaults to `false`, so a redirect is reported as its `3xx` status.
Skip TLS certificate verification. Defaults to `false`.
### Returns
Identifier for this fetch request.
The page content. A string for `raw` and `markdown`, an object for `json`.
MIME type reported by the target.
`utf-8` for text, `base64` for binary responses.
Response headers from the target.
HTTP status code returned by the target. A fetch that reaches a `404` succeeds as a call and reports `404` here.
## When to use a browser instead
`fetch()` never renders the page. It performs an HTTP request and converts the response, which is exactly why it is fast, and also why it cannot see anything that only exists after JavaScript runs.
Reach for [`browserbase.launch()`](/v4/configuration/browser) and [`extract()`](/v4/basics/extract) when the content is client-rendered, sits behind a login, or appears only after interaction such as scrolling, clicking a tab, or dismissing a modal. A common pattern is to try `fetch()` first and fall back to a browser session when the content you need is missing.
## Use cases
Retrieve up-to-date information to produce more accurate answers.
Pull the documentation page for a library straight into context as markdown.
Pull fresh, domain-specific information tailored to a given industry or knowledge vertical.
Regularly collect news, people, company, or other frequently updated information for internal or production workflows.
## Limits
| Limit | Value |
| - | - |
| Content size | 5 MB |
| Request timeout | 60 seconds |
| JavaScript execution | Not supported (the page is never rendered) |
| PDF conversion | PDFs cannot be converted to `markdown` or `json` |
## Next steps
Find the URLs worth fetching in the first place.
Endpoint details, pricing, and the underlying REST API.
# Search
Source: https://docs.stagehand.dev/v4/add-ons/search
Search the live web and hand your agent a ranked list of URLs
## What is `search()`?
An agent that doesn't know where to start has to launch a browser, load a search engine, wait for the page, and read a result list before it can do anything useful. `browserbase.search()` collapses that into a single API call: you send a query, you get back ranked results with URLs, titles, and metadata.
```typescript theme={null}
import { browserbase } from "@browserbasehq/stagehand";
const searchResult = await browserbase.search({
apiKey: process.env.BROWSERBASE_API_KEY,
query: "browser agent frameworks",
numResults: 5,
});
for (const result of searchResult.results) {
console.log(`${result.title}: ${result.url}`);
}
```
```python theme={null}
import os
from stagehand import browserbase
search_result = await browserbase.search(
api_key=os.environ["BROWSERBASE_API_KEY"],
query="browser agent frameworks",
num_results=5,
)
for result in search_result.results:
print(f"{result.title}: {result.url}")
```
```go theme={null}
numResults := 5
searchResult, err := stagehand.SearchBrowserbase(ctx, stagehand.BrowserbaseSearchOptions{
APIKey: os.Getenv("BROWSERBASE_API_KEY"),
Query: "browser agent frameworks",
NumResults: &numResults,
})
if err != nil {
return err
}
for _, result := range searchResult.Results {
fmt.Printf("%s: %s\n", result.Title, result.URL)
}
```
`search()` is a Browserbase cloud feature. The query runs on Browserbase infrastructure and needs a Browserbase API key, so local browsers have no equivalent.
## Why use `search()`?
Search spans the whole web, so your agent can discover sources on any topic instead of being limited to domains you hardcoded.
Results come back as structured objects, ranked for relevance and ready for the agent to act on, with no result page to parse.
Every query hits the live web rather than a cached index, and it returns without the cost of booting a browser and rendering a search page.
Each result carries the URL, title, and metadata rather than full page excerpts. The agent decides which pages are worth fetching, and the context window stays lean.
## Setup
`search()` ships with the Stagehand SDK. The only requirement is a [Browserbase API key](https://www.browserbase.com/overview):
```bash theme={null}
export BROWSERBASE_API_KEY="bb_live_..."
```
```typescript theme={null}
import "dotenv/config";
import { browserbase } from "@browserbasehq/stagehand";
const searchResult = await browserbase.search({
apiKey: process.env.BROWSERBASE_API_KEY,
query: "stagehand browser automation",
});
console.log(searchResult.requestId, searchResult.results.length);
```
```python theme={null}
import asyncio
import os
from stagehand import browserbase
async def main() -> None:
search_result = await browserbase.search(
api_key=os.environ["BROWSERBASE_API_KEY"],
query="stagehand browser automation",
)
print(search_result.request_id, len(search_result.results))
if __name__ == "__main__":
asyncio.run(main())
```
```go theme={null}
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
stagehand "github.com/browserbase/stagehand/packages/sdk-go/v4"
)
func main() {
if err := run(context.Background()); err != nil {
log.Fatal(err)
}
}
func run(ctx context.Context) error {
apiKey := os.Getenv("BROWSERBASE_API_KEY")
if apiKey == "" {
return errors.New("BROWSERBASE_API_KEY is required")
}
searchResult, err := stagehand.SearchBrowserbase(ctx, stagehand.BrowserbaseSearchOptions{
APIKey: apiKey,
Query: "stagehand browser automation",
})
if err != nil {
return err
}
fmt.Println(searchResult.RequestID, len(searchResult.Results))
return nil
}
```
## Response
A search returns the query it ran, a request ID you can quote in support requests, and the ranked results. `id`, `title`, and `url` are always present; the rest are filled in when the source exposes them.
```json theme={null}
{
"query": "browser agent frameworks",
"requestId": "req_01JB8Z3K7Q2M4N",
"results": [
{
"id": "https://docs.stagehand.dev/v4/first-steps/introduction",
"title": "Introduction - Stagehand",
"url": "https://docs.stagehand.dev/v4/first-steps/introduction",
"author": null,
"favicon": "https://docs.stagehand.dev/favicon.ico",
"image": null,
"publishedDate": "2025-11-04T00:00:00.000Z"
}
]
}
```
Field names follow each language's conventions: `requestId` and `publishedDate` in TypeScript, `request_id` and `published_date` in Python, and `RequestID` and `PublishedDate` on the Go structs.
## API reference
### Parameters
Your Browserbase API key. Search is billed to the project that owns the key.
The search query, 1–200 characters.
How many results to return, between 1 and 25. Defaults to 10.
Browserbase API origin override. Defaults to `https://api.browserbase.com`.
### Returns
The query that was executed.
Identifier for this search request.
Results ordered by relevance.
Stable identifier for the result.
Page title.
Canonical URL of the page. Pass it straight to [`fetch()`](/v4/add-ons/fetch) or `page.goto()`.
Author, when the source declares one.
Favicon URL, when available.
Representative image URL, when available.
ISO 8601 publication date, when available.
Stagehand rejects invalid input before the request leaves your process: an empty query, a query over 200 characters, or a `numResults` outside 1–25 raises locally rather than costing you a round trip.
## Search, then read
`search()` deliberately returns pointers rather than page bodies. Pair it with [`fetch()`](/v4/add-ons/fetch) to pull the content of the one or two pages that matter, and reach for a full browser session only when the page needs interaction.
```typescript theme={null}
const { results } = await browserbase.search({
apiKey: process.env.BROWSERBASE_API_KEY,
query: "browser agent frameworks",
numResults: 5,
});
const page = await browserbase.fetch({
apiKey: process.env.BROWSERBASE_API_KEY,
url: results[0].url,
format: "markdown",
});
console.log(page.content);
```
```python theme={null}
search_result = await browserbase.search(
api_key=os.environ["BROWSERBASE_API_KEY"],
query="browser agent frameworks",
num_results=5,
)
fetched = await browserbase.fetch(
api_key=os.environ["BROWSERBASE_API_KEY"],
url=search_result.results[0].url,
format="markdown",
)
print(fetched.content)
```
```go theme={null}
numResults := 5
searchResult, err := stagehand.SearchBrowserbase(ctx, stagehand.BrowserbaseSearchOptions{
APIKey: os.Getenv("BROWSERBASE_API_KEY"),
Query: "browser agent frameworks",
NumResults: &numResults,
})
if err != nil {
return err
}
if len(searchResult.Results) == 0 {
return errors.New("search returned no results")
}
fetched, err := stagehand.FetchBrowserbase(ctx, stagehand.BrowserbaseFetchOptions{
APIKey: os.Getenv("BROWSERBASE_API_KEY"),
URL: searchResult.Results[0].URL,
Format: stagehand.BrowserbaseFetchFormatMarkdown,
})
if err != nil {
return err
}
content, ok := fetched.Content.AsString()
if !ok {
return errors.New("fetch returned non-string content")
}
fmt.Println(content)
```
## Use cases
Retrieve up-to-date information to produce more accurate answers.
Find the best library, or the documentation page for a given technical requirement.
Conduct in-depth web research across the whole web.
Find relevant sources fast enough for low-latency voice interactions.
## Limits
| Limit | Value |
| - | - |
| Query length | 1–200 characters |
| Results per query | 1–25 (default 10) |
| Rate limit | 120 requests per minute, per project |
Exceeding the rate limit returns a `429`. Back off and retry rather than looping.
## Next steps
Turn any URL you discovered into agent-ready markdown or JSON.
Endpoint details, pricing, and the underlying REST API.
# Act
Source: https://docs.stagehand.dev/v4/basics/act
Interact with a web page
## What is `act()`?
```typescript theme={null}
await stagehand.act("click on add to cart");
```
```python theme={null}
await stagehand.act("click on add to cart")
```
```go theme={null}
if _, err := client.Act(ctx, stagehand.ActInstruction("click on add to cart"), nil); err != nil {
return err
}
```
`act` performs one action on a web page. Chain single-step calls to build automations that survive website changes.
`act` accepts either a natural language instruction or an `Action` returned by [`observe()`](/v4/basics/observe). Passing an instruction runs model inference to find the target; passing an `Action` replays it deterministically with no inference at all.
## Why use `act()`?
Write automation in plain English. No selectors or complex syntax.
Build automations step by step. Define exactly what happens at every moment.
Turn on `selfHeal` to re-infer an action when its recorded selector breaks.
Cache actions to avoid LLM calls and ensure consistent execution across runs.
## Using `act()`
Use `act` to perform single actions in your automation. Here's how to click a button:
```typescript theme={null}
await page.goto("https://example-store.com");
await stagehand.act("click the add to cart button");
```
```python theme={null}
await page.goto("https://example-store.com")
await stagehand.act("click the add to cart button")
```
```go theme={null}
if _, err := page.Goto(ctx, "https://example-store.com", nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("click the add to cart button"), nil); err != nil {
return err
}
```
**iFrame and Shadow DOM support** Stagehand automatically handles iFrame traversal and shadow DOM elements without requiring additional configuration. Page snapshots merge every frame's accessibility tree by default.
With `act`, breaking complex actions into small, single-step actions works best. If you need to orchestrate multi-step flows, use multiple `act` commands.
| Action | `method` | Example instruction |
| - | - | - |
| Click | `click` | `click the button` |
| Double click | `doubleClick` | `double click the file name` |
| Fill | `fill` | `fill the field with ` |
| Type | `type` | `type into the search box` |
| Press | `press` | `press in the search field` |
| Hover | `hover` | `hover over the account menu` |
| Scroll | `scrollTo` | `scroll to ` |
| Scroll one screen | `nextChunk` / `prevChunk` | `scroll down one page` |
| Select from dropdown | `selectOptionFromDropdown` | `select from the dropdown` |
| Drag and drop | `dragAndDrop` | `drag the card onto the Done column` |
The `method` column is the value you will see on an `Action` returned by [`observe()`](/v4/basics/observe).
### Return value of `act()`
When you use `act()`, Stagehand returns a result with two fields: `data` holds the action payload, and `metadata` carries the action ID and server-side cache status.
```typescript theme={null}
{
data: {
success: true,
message: 'Action [click] performed successfully on selector: xpath=/html[1]/body[1]/div[1]/span[1]',
actionDescription: 'Favorite Colour',
actions: [
{
selector: 'xpath=/html[1]/body[1]/div[1]/span[1]',
description: 'Favorite Colour',
method: 'click',
arguments: []
},
{
selector: 'xpath=/html[1]/body[1]/div[2]/div[1]/section[1]/div[1]/div[1]/div[25]',
description: 'Peach',
method: 'click',
arguments: []
}
]
},
metadata: {
actionId: 'act_01HZY...',
cache: { status: 'MISS', missReason: 'not_found' }
}
}
```
```python theme={null}
ActResult(
data=ActResultData(
success=True,
message="Action [click] performed successfully on selector: xpath=/html[1]/body[1]/div[1]/span[1]",
action_description="Favorite Colour",
actions=[
Action(
selector="xpath=/html[1]/body[1]/div[1]/span[1]",
description="Favorite Colour",
method="click",
arguments=[],
),
Action(
selector="xpath=/html[1]/body[1]/div[2]/div[1]/section[1]/div[1]/div[1]/div[25]",
description="Peach",
method="click",
arguments=[],
),
],
),
metadata=StagehandResultMetadata(
action_id="act_01HZY...",
cache={"status": "MISS", "miss_reason": "not_found"},
),
)
```
```go theme={null}
clickMethod := "click"
actionID := "act_01HZY..."
cacheMetadata := stagehand.CacheMetadata{Status: stagehand.CacheStatusMISS}
result := stagehand.ActResult{
Data: stagehand.ActResultData{
Success: true,
Message: "Action [click] performed successfully on selector: xpath=/html[1]/body[1]/div[1]/span[1]",
ActionDescription: "Favorite Colour",
Actions: []stagehand.Action{
{
Selector: "xpath=/html[1]/body[1]/div[1]/span[1]",
Description: "Favorite Colour",
Method: &clickMethod,
Arguments: []string{},
},
{
Selector: "xpath=/html[1]/body[1]/div[2]/div[1]/section[1]/div[1]/div[1]/div[25]",
Description: "Peach",
Method: &clickMethod,
Arguments: []string{},
},
},
},
Metadata: stagehand.StagehandResultMetadata{
ActionID: &actionID,
Cache: &cacheMetadata,
},
}
// Read the fields you care about off the result
fmt.Println(result.Data.Message, result.Metadata.Cache.Status)
```
Break your task into single-step actions.
```typescript theme={null}
// Break it into single-step actions
await stagehand.act("open the filters panel");
await stagehand.act("choose 4-star rating");
await stagehand.act("click the apply button");
```
```python theme={null}
# Break it into single-step actions
await stagehand.act("open the filters panel")
await stagehand.act("choose 4-star rating")
await stagehand.act("click the apply button")
```
```go theme={null}
// Break it into single-step actions
if _, err := client.Act(ctx, stagehand.ActInstruction("open the filters panel"), nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("choose 4-star rating"), nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("click the apply button"), nil); err != nil {
return err
}
```
Multi-step instructions are unreliable. Sequence them yourself instead.
```typescript theme={null}
// Too complex - trying to do multiple things at once
await stagehand.act("open the filters panel, choose 4-star rating, and click apply");
```
```python theme={null}
# Too complex - trying to do multiple things at once
await stagehand.act("open the filters panel, choose 4-star rating, and click apply")
```
```go theme={null}
// Too complex - trying to do multiple things at once
if _, err := client.Act(ctx, stagehand.ActInstruction("open the filters panel, choose 4-star rating, and click apply"), nil); err != nil {
return err
}
```
## Advanced configuration
You can pass additional options to configure the model, timeout, variables, target page, and target locator:
```typescript theme={null}
// Custom model configuration
await stagehand.act("choose 'Peach' from the favorite color dropdown", {
model: {
modelName: "google/gemini-3.8-flash",
apiKey: process.env.GOOGLE_API_KEY,
},
timeout: 10000,
locator: page.locator("form"),
ignoreLocators: [page.locator(".promo-modal")],
});
```
```python theme={null}
# Custom model configuration
import os
from stagehand import ModelConfig
await stagehand.act(
"choose 'Peach' from the favorite color dropdown",
model=ModelConfig(
model_name="google/gemini-3.8-flash",
api_key=os.environ["GOOGLE_API_KEY"],
),
timeout=10000,
locator=page.locator("form"),
ignore_locators=[page.locator(".promo-modal")],
)
```
```go theme={null}
// Custom model configuration
modelAPIKey := os.Getenv("GOOGLE_API_KEY")
model := stagehand.ModelConfig{
ModelName: "google/gemini-3.8-flash",
APIKey: &modelAPIKey,
}
timeout := 10000.0
if _, err := client.Act(ctx, stagehand.ActInstruction("choose 'Peach' from the favorite color dropdown"), &stagehand.StagehandClientActOptions{
Page: page,
Model: &model,
Timeout: &timeout,
Locator: page.Locator("form"),
IgnoreLocators: []*stagehand.PageLocator{
page.Locator(".promo-modal"),
},
}); err != nil {
return err
}
```
Target locators only affect instruction-based `act()` calls. When you pass an `Action` returned by `observe()`, Stagehand replays that action's selector directly.
### Server-side caching
`cache` requires a Browserbase browser and a Browserbase API key. It has no effect on local browsers.
When running on Browserbase, Stagehand can cache `act()` results server-side. Repeated calls with the same inputs return instantly without consuming LLM tokens. Caching is on by default. Set `cache` on the constructor to change it for the instance, or override it per call:
Instruction-based `act()` calls with a target locator or ignored locators bypass the server-side cache and report `metadata.cache.status` as `DISABLED`.
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
// Enable server-side caching for the entire instance
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
});
const stagehand = await Stagehand.create({
browser,
cache: true,
});
// Or disable it for a single call
await stagehand.act("click the login button", { cache: false });
// Or lower the hit-count threshold for a single call
await stagehand.act("click the login button", { cache: { threshold: 1 } });
// Check whether a result was served from cache
const result = await stagehand.act("click the login button");
console.log(result.metadata.cache.status); // "HIT", "MISS", or "DISABLED"
```
```python theme={null}
import os
from stagehand import CacheOptions, Stagehand, browserbase
# Enable server-side caching for the entire instance
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
)
stagehand = await Stagehand.create(
browser=browser,
cache=True,
)
# Or disable it for a single call
await stagehand.act("click the login button", cache=False)
# Or lower the hit-count threshold for a single call
await stagehand.act("click the login button", cache=CacheOptions(threshold=1))
# Check whether a result was served from cache
result = await stagehand.act("click the login button")
print(result.metadata.cache.status) # "HIT", "MISS", or "DISABLED"
```
```go theme={null}
// Enable server-side caching for the entire instance
apiKey := os.Getenv("BROWSERBASE_API_KEY")
instanceCache := stagehand.CacheEnabled(true)
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{APIKey: apiKey})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Cache: &instanceCache,
})
// Or disable it for a single call
off := stagehand.CacheEnabled(false)
if _, err := client.Act(ctx, stagehand.ActInstruction("click the login button"), &stagehand.StagehandClientActOptions{
Cache: &off,
}); err != nil {
return err
}
// Or lower the hit-count threshold for a single call
eager := stagehand.CacheWithThreshold(1)
if _, err := client.Act(ctx, stagehand.ActInstruction("click the login button"), &stagehand.StagehandClientActOptions{
Cache: &eager,
}); err != nil {
return err
}
// Check whether a result was served from cache
result, err := client.Act(ctx, stagehand.ActInstruction("click the login button"), nil)
if err != nil {
return err
}
fmt.Println(result.Metadata.Cache.Status) // "HIT", "MISS", or "DISABLED"
```
Learn how the cache key is built, what the threshold does, and when results are invalidated.
### Using with custom pages
Stagehand v4 drives the browser directly over the Chrome DevTools Protocol, so there is no Puppeteer, Playwright, or Patchright page interop. Instead, target any page Stagehand manages by passing the `page` option:
```typescript theme={null}
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
});
const stagehand = await Stagehand.create({ browser });
// Open a second page in the same browser context
const blogPage = await browser.context.newPage("https://www.example.com/blog");
// Use act with that specific page
await stagehand.act("click the next page button", {
page: blogPage,
});
```
```python theme={null}
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
)
stagehand = await Stagehand.create(browser=browser)
# Open a second page in the same browser context
blog_page = await browser.context.new_page("https://www.example.com/blog")
# Use act with that specific page
await stagehand.act("click the next page button", page=blog_page)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{APIKey: apiKey})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{Browser: browser})
if err != nil {
return err
}
browserContext, err := browser.Context()
if err != nil {
return err
}
// Open a second page in the same browser context
blogPage, err := browserContext.NewPage(ctx, "https://www.example.com/blog")
if err != nil {
return err
}
// Use Act with that specific page
_, err = client.Act(ctx, stagehand.ActInstruction("click the next page button"), &stagehand.StagehandClientActOptions{
Page: blogPage,
})
```
This works with:
* **Active page:** omit `page` and Stagehand uses `context.activePage()` (default)
* **Existing pages:** receive the list from `context.pages()` first (await it in TypeScript and Python), then index into it
* **New pages:** create one with `context.newPage()`
* **Existing browsers:** attach to a browser you already run with `localBrowser.connect()`
See the full `Stagehand` reference for detailed parameter documentation, return values, and advanced examples.
## Best practices
### Ensure reliable actions
Use `observe()` to discover candidate actions on the current page and plan reliably. It returns a list of suggested actions (with selector, description, method, and arguments). Inspect the action before you commit to it, then hand it straight back to `act`:
```typescript theme={null}
const { data: actions } = await stagehand.observe("click the login button");
const [action] = actions;
if (action?.method === "click") {
// No inference: Stagehand replays the observed action
await stagehand.act(action);
}
```
```python theme={null}
result = await stagehand.observe("click the login button")
if result.data and result.data[0].method == "click":
# No inference: Stagehand replays the observed action
await stagehand.act(result.data[0])
```
```go theme={null}
instruction := "click the login button"
observed, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
if len(observed.Data) > 0 && observed.Data[0].Method != nil && *observed.Data[0].Method == "click" {
// No inference: Stagehand replays the observed action
if _, err := client.Act(ctx, stagehand.ObservedAction(observed.Data[0]), nil); err != nil {
return err
}
}
```
Replaying an `Action` skips model inference, the page snapshot, and the DOM-settle wait, and it does not consult the server-side cache. If the recorded selector no longer resolves and `selfHeal` is on, Stagehand re-infers the action and retries once.
Plan actions with `observe()` before executing with `act`.
### Reduce model costs
Enable server-side caching with `cache` when running on Browserbase. Stagehand records the action on the first run; once the hit count meets the threshold, the cache serves identical calls without an LLM call.
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
});
const stagehand = await Stagehand.create({
browser,
cache: { threshold: 1 }, // Actions are cached after one identical result
});
// First run makes an LLM call and records the action
await stagehand.act("click the login button");
// Second run is served from the cache
await stagehand.act("click the login button");
```
```python theme={null}
import os
from stagehand import CacheOptions, Stagehand, browserbase
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
)
stagehand = await Stagehand.create(
browser=browser,
cache=CacheOptions(threshold=1), # Actions are cached after one identical result
)
# First run makes an LLM call and records the action
await stagehand.act("click the login button")
# Second run is served from the cache
await stagehand.act("click the login button")
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
cache := stagehand.CacheWithThreshold(1) // Actions are cached after one identical result
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{APIKey: apiKey})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{Browser: browser, Cache: &cache})
if err != nil {
return err
}
// First run makes an LLM call and records the action
if _, err := client.Act(ctx, stagehand.ActInstruction("click the login button"), nil); err != nil {
return err
}
// Second run is served from the cache
if _, err := client.Act(ctx, stagehand.ActInstruction("click the login button"), nil); err != nil {
return err
}
```
The cache lives on Browserbase, keyed on the instruction, page content, and call options, so it persists across script executions and across machines. The model configuration is deliberately excluded from the key, so switching models does not invalidate your cache.
Learn advanced caching techniques and patterns for optimal performance.
### Secure your automations
Variables are **not shared with LLM providers**. Use them for passwords, API keys, and other sensitive data. Stagehand exposes only the variable names to the model and substitutes the real values locally, so results record the placeholder rather than the secret. One exception: [server-side caching](/v4/best-practices/caching) is on by default, and it sends variable values to the cache service as part of the request. Set `cache: false` on calls that carry credentials.
Load sensitive data from environment variables. Never hardcode API keys, passwords, or other secrets directly in your code.
```typescript theme={null}
// Variables use %variableName% syntax in the instruction
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.USER_PASSWORD },
});
await stagehand.act("click the login button");
```
```python theme={null}
# Variables use %variable_name% syntax in the instruction
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["USER_PASSWORD"]},
)
await stagehand.act("click the login button")
```
```go theme={null}
// Variables use %variableName% syntax in the instruction
if _, err := client.Act(ctx, stagehand.ActInstruction("type %username% into the email field"), &stagehand.StagehandClientActOptions{
Variables: stagehand.Variables{
"username": stagehand.PrimitiveVariable(stagehand.StringVariable("user@example.com")),
},
}); err != nil {
return err
}
userPassword := os.Getenv("USER_PASSWORD")
if _, err := client.Act(ctx, stagehand.ActInstruction("type %password% into the password field"), &stagehand.StagehandClientActOptions{
Variables: stagehand.Variables{
"password": stagehand.PrimitiveVariable(stagehand.StringVariable(userPassword)),
},
}); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("click the login button"), nil); err != nil {
return err
}
```
When handling sensitive data, turn logging off in your Stagehand configuration to prevent secrets from appearing in logs. See the [logging guide](/v4/configuration/logging) for more details.
Complete guide to persisting and securing browser state across sessions.
## Troubleshooting
**Problem**: `act` fails with "method not supported" error
**Solutions**:
* Use clear and detailed instructions for what you want to accomplish
* Review the [Stagehand evals](https://stagehand.dev/evals) to find the best models for your use case
* Use [`observe()`](/v4/basics/observe) and verify the resulting action is within a list of expected actions
**Solution 1: Validate with observe**
```typescript theme={null}
const prompt = "click the submit button";
const expectedMethod = "click";
try {
await stagehand.act(prompt);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("method not supported")) {
// Observe the same prompt to get the planned action
const { data: actions } = await stagehand.observe(prompt);
const [action] = actions;
if (action && action.method === expectedMethod) {
await stagehand.act(action);
} else {
throw new Error(`Unsupported method: expected "${expectedMethod}", got "${action?.method}"`);
}
} else {
throw error;
}
}
```
```python theme={null}
prompt = "click the submit button"
expected_method = "click"
try:
await stagehand.act(prompt)
except Exception as error:
if "method not supported" not in str(error):
raise
# Observe the same prompt to get the planned action
observed = await stagehand.observe(prompt)
action = observed.data[0] if observed.data else None
if action is not None and action.method == expected_method:
await stagehand.act(action)
else:
method = action.method if action else None
raise RuntimeError(f'Unsupported method: expected "{expected_method}", got "{method}"')
```
```go theme={null}
prompt := "click the submit button"
expectedMethod := "click"
if _, err := client.Act(ctx, stagehand.ActInstruction(prompt), nil); err != nil {
if !strings.Contains(err.Error(), "method not supported") {
return err
}
// Observe the same prompt to get the planned action
observed, observeErr := client.Observe(ctx, &prompt, nil)
if observeErr != nil {
return observeErr
}
if len(observed.Data) == 0 {
return fmt.Errorf("no action found for %q", prompt)
}
action := observed.Data[0]
if action.Method == nil || *action.Method != expectedMethod {
return fmt.Errorf("unsupported method: expected %q, got %v", expectedMethod, action.Method)
}
if _, err := client.Act(ctx, stagehand.ObservedAction(action), nil); err != nil {
return err
}
}
```
**Solution 2: Retry with exponential backoff**
```typescript theme={null}
// Retry with exponential backoff for intermittent issues
const prompt = "click the submit button";
const maxRetries = 3;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
await stagehand.act(prompt, { timeout: 10000 + (attempt * 5000) });
break; // Success, exit retry loop
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("method not supported") && attempt < maxRetries) {
// Exponential backoff: wait 2^attempt seconds
const delay = Math.pow(2, attempt) * 1000;
console.log(`Retry ${attempt + 1}/${maxRetries} after ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
} else {
throw error;
}
}
}
```
```python theme={null}
# Retry with exponential backoff for intermittent issues
import asyncio
prompt = "click the submit button"
max_retries = 3
for attempt in range(max_retries + 1):
try:
await stagehand.act(prompt, timeout=10000 + (attempt * 5000))
break # Success, exit retry loop
except Exception as error:
if "method not supported" in str(error) and attempt < max_retries:
# Exponential backoff: wait 2^attempt seconds
delay = 2**attempt
print(f"Retry {attempt + 1}/{max_retries} after {delay * 1000}ms")
await asyncio.sleep(delay)
else:
raise
```
```go theme={null}
// Retry with exponential backoff for intermittent issues
prompt := "click the submit button"
const maxRetries = 3
for attempt := 0; attempt <= maxRetries; attempt++ {
timeout := float64(10000 + attempt*5000)
_, err := client.Act(ctx, stagehand.ActInstruction(prompt), &stagehand.StagehandClientActOptions{
Timeout: &timeout,
})
if err == nil {
break // Success, exit retry loop
}
if !strings.Contains(err.Error(), "method not supported") || attempt == maxRetries {
return err
}
// Exponential backoff: wait 2^attempt seconds
delay := time.Duration(1<
**Problem**: `act` times out or fails to complete action (often due to element not found)
**Solutions**:
* Ensure page has fully loaded
* Check if content is in iframes: Stagehand traverses them automatically, but a target locator can help
* Increase action timeout
* Use `observe()` first to verify element exists
* Raise `domSettleTimeoutMs` on the constructor if the page keeps mutating after load
```typescript theme={null}
// Handle timeout and element not found issues
try {
await stagehand.act("click the submit button", { timeout: 30000 });
} catch (error) {
// Check if page is fully loaded
await page.waitForLoadState("domcontentloaded");
// Use observe to check element state
const { data: elements } = await stagehand.observe("find the submit button");
if (elements.length > 0) {
console.log("Element found, trying more specific instruction");
await stagehand.act("click the submit button at the bottom of the form");
} else {
console.log("Element not found, trying alternative selector");
await stagehand.act("click the button with text 'Submit'");
}
}
```
```python theme={null}
# Handle timeout and element not found issues
try:
await stagehand.act("click the submit button", timeout=30000)
except Exception:
# Check if page is fully loaded
await page.wait_for_load_state("domcontentloaded")
# Use observe to check element state
observed = await stagehand.observe("find the submit button")
if observed.data:
print("Element found, trying more specific instruction")
await stagehand.act("click the submit button at the bottom of the form")
else:
print("Element not found, trying alternative selector")
await stagehand.act("click the button with text 'Submit'")
```
```go theme={null}
// Handle timeout and element not found issues
timeout := 30000.0
if _, err := client.Act(ctx, stagehand.ActInstruction("click the submit button"), &stagehand.StagehandClientActOptions{
Timeout: &timeout,
}); err != nil {
// Check if page is fully loaded
if err := page.WaitForLoadState(ctx, stagehand.LoadStateDOMContentLoaded, nil); err != nil {
return err
}
// Use Observe to check element state
instruction := "find the submit button"
observed, observeErr := client.Observe(ctx, &instruction, nil)
if observeErr != nil {
return observeErr
}
if len(observed.Data) > 0 {
fmt.Println("Element found, trying more specific instruction")
_, err = client.Act(ctx, stagehand.ActInstruction("click the submit button at the bottom of the form"), nil)
} else {
fmt.Println("Element not found, trying alternative selector")
_, err = client.Act(ctx, stagehand.ActInstruction("click the button with text 'Submit'"), nil)
}
if err != nil {
return err
}
}
```
**Problem**: `act` performs action on wrong element
**Solutions**:
* Be more specific in instructions: include visual cues, position, or context
* Use `observe()` to preview which element will be selected
* Add contextual information: "the search button in the header"
* Use unique identifiers when available
```typescript theme={null}
// More precise element targeting
// Instead of:
await stagehand.act("click the button");
// Use specific context:
await stagehand.act("click the red 'Delete' button next to the user John Smith");
// Or preview with observe first:
const { data: actions } = await stagehand.observe("click the submit button in the checkout form");
const [action] = actions;
if (action?.description.includes("checkout")) {
await stagehand.act(action);
}
```
```python theme={null}
# More precise element targeting
# Instead of:
await stagehand.act("click the button")
# Use specific context:
await stagehand.act("click the red 'Delete' button next to the user John Smith")
# Or preview with observe first:
observed = await stagehand.observe(
"click the submit button in the checkout form"
)
if observed.data and "checkout" in observed.data[0].description:
await stagehand.act(observed.data[0])
```
```go theme={null}
// More precise element targeting
// Instead of:
_, err := client.Act(ctx, stagehand.ActInstruction("click the button"), nil)
// Use specific context:
_, err = client.Act(ctx, stagehand.ActInstruction("click the red 'Delete' button next to the user John Smith"), nil)
// Or preview with Observe first:
instruction := "click the submit button in the checkout form"
observed, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
if len(observed.Data) > 0 && strings.Contains(observed.Data[0].Description, "checkout") {
if _, err := client.Act(ctx, stagehand.ObservedAction(observed.Data[0]), nil); err != nil {
return err
}
}
```
## Next steps
Use `observe()` to plan actions before executing them.
Speed up repeated automations by caching actions.
Use `extract` with a data schema to pull clean, typed data from any page.
Target a specific page with the `page` option.
# Extract
Source: https://docs.stagehand.dev/v4/basics/extract
Extract structured data from a webpage
## What is `extract()`?
```typescript theme={null}
await stagehand.extract(
"extract the name of the repository",
z.object({ name: z.string() }),
);
```
```python theme={null}
class Repository(BaseModel):
name: str
await stagehand.extract(
"extract the name of the repository",
Repository,
)
```
```go theme={null}
type repository struct {
Name string `json:"name"`
}
extracted, err := stagehand.Extract[repository](
ctx,
client,
"extract the name of the repository",
nil,
)
```
`extract()` grabs structured data from a webpage. Every call takes an instruction and an output shape: [Zod](https://github.com/colinhacks/zod) in TypeScript, [Pydantic](https://docs.pydantic.dev) in Python, and a Go type parameter whose JSON Schema Stagehand derives automatically. Stagehand validates the result against that shape before returning it, so what you get back is already typed.
## Why use `extract()`?
Turn messy webpage data into clean objects that follow a schema.
Build resilient extractions that don't break when the website changes
## Return value
`extract()` returns a result with two fields: `data` holds the extracted value, and `metadata` carries the action ID and server-side cache status. The shape of `data` follows the schema or Go type you supplied:
When extracting with an object schema, the return type is inferred from that schema:
```typescript theme={null}
const { data } = await stagehand.extract(
"extract product details",
z.object({
name: z.string(),
price: z.number(),
inStock: z.boolean(),
}),
);
```
```python theme={null}
class Product(BaseModel):
name: str
price: float
in_stock: bool
result = await stagehand.extract(
"extract product details",
Product,
)
```
```go theme={null}
type product struct {
Name string `json:"name"`
Price float64 `json:"price"`
InStock bool `json:"in_stock"`
}
extracted, err := stagehand.Extract[product](
ctx,
client,
"extract product details",
nil,
)
```
**Example result:**
```typescript theme={null}
{
name: "Wireless Mouse",
price: 29.99,
inStock: true
}
```
```python theme={null}
Product(name="Wireless Mouse", price=29.99, in_stock=True)
```
```go theme={null}
result := product{Name: "Wireless Mouse", Price: 29.99, InStock: true}
fmt.Println(result.Name, result.Price, result.InStock)
```
To extract a list, wrap it in a field on your schema:
```typescript theme={null}
const { data } = await stagehand.extract(
"extract all apartment listings",
z.object({
apartments: z.array(
z.object({
address: z.string(),
price: z.string(),
sqft: z.number(),
}),
),
}),
);
```
```python theme={null}
class Apartment(BaseModel):
address: str
price: str
sqft: int
class Apartments(BaseModel):
apartments: list[Apartment]
result = await stagehand.extract(
"extract all apartment listings",
Apartments,
)
```
```go theme={null}
type apartment struct {
Address string `json:"address"`
Price string `json:"price"`
Sqft int `json:"sqft"`
}
type apartments struct {
Apartments []apartment `json:"apartments"`
}
extracted, err := stagehand.Extract[apartments](
ctx,
client,
"extract all apartment listings",
nil,
)
```
**Example result:**
```typescript theme={null}
{
apartments: [
{
address: "123 Main St",
price: "$1,200/mo",
sqft: 750
},
{
address: "456 Oak Ave",
price: "$1,500/mo",
sqft: 900
}
]
}
```
```python theme={null}
Apartments(apartments=[
Apartment(address="123 Main St", price="$1,200/mo", sqft=750),
Apartment(address="456 Oak Ave", price="$1,500/mo", sqft=900),
])
```
```go theme={null}
result := apartments{Apartments: []apartment{
{Address: "123 Main St", Price: "$1,200/mo", Sqft: 750},
{Address: "456 Oak Ave", Price: "$1,500/mo", Sqft: 900},
}}
fmt.Println(result.Apartments[0].Address, result.Apartments[0].Sqft)
```
To extract a single value, give it a named field:
```typescript theme={null}
const { data } = await stagehand.extract(
"extract the price",
z.object({ price: z.number() }),
);
```
```python theme={null}
class Price(BaseModel):
price: float
result = await stagehand.extract(
"extract the price",
Price,
)
```
```go theme={null}
type price struct {
Price float64 `json:"price"`
}
extracted, err := stagehand.Extract[price](
ctx,
client,
"extract the price",
nil,
)
```
**Example result:**
```typescript theme={null}
{
price: 19.99
}
```
```python theme={null}
Price(price=19.99)
```
```go theme={null}
result := price{Price: 19.99}
fmt.Println(result.Price)
```
You can also extract strings, booleans, and URLs:
```typescript theme={null}
const { data } = await stagehand.extract(
"extract the contact page link",
z.object({ url: z.url() }),
);
```
```python theme={null}
from pydantic import AnyUrl
class ContactLink(BaseModel):
url: AnyUrl
result = await stagehand.extract(
"extract the contact page link",
ContactLink,
)
```
```go theme={null}
type contactLink struct {
URL string `json:"url" jsonschema:"format=uri"`
}
extracted, err := stagehand.Extract[contactLink](
ctx,
client,
"extract the contact page link",
nil,
)
```
## Advanced configuration
You can pass additional options to configure the model, timeout, locator scope, and whether to include a screenshot:
```typescript theme={null}
const page = await stagehand.browser.context.activePage();
const result = await stagehand.extract(
"extract the repository name",
z.object({ name: z.string() }),
{
model: {
modelName: "anthropic/claude-sonnet-5",
apiKey: process.env.ANTHROPIC_API_KEY,
},
timeout: 30000,
locator: page.locator("xpath=//header"), // Focus on specific area
},
);
```
```python theme={null}
import os
from stagehand import ModelConfig
page = await stagehand.browser.context.active_page()
result = await stagehand.extract(
"extract the repository name",
Repository,
model=ModelConfig(
model_name="anthropic/claude-sonnet-5",
api_key=os.environ["ANTHROPIC_API_KEY"],
),
timeout=30000,
locator=page.locator("xpath=//header"), # Focus on specific area
)
```
```go theme={null}
modelAPIKey := os.Getenv("ANTHROPIC_API_KEY")
model := stagehand.ModelConfig{
ModelName: "anthropic/claude-sonnet-5",
APIKey: &modelAPIKey,
}
timeout := 30000.0
result, err := stagehand.Extract[repository](ctx, client, "extract the repository name", &stagehand.StagehandClientExtractOptions{
Page: page,
Model: &model,
Timeout: &timeout,
Locator: page.Locator("xpath=//header"), // Focus on specific area
})
if err != nil {
return err
}
fmt.Println(result.Data.Name)
```
### Server-side caching
`cache` requires a Browserbase browser and a Browserbase API key. It has no effect on local browsers.
When running on Browserbase, Stagehand can cache `extract()` results server-side. Repeated calls with the same inputs return instantly without consuming LLM tokens. Caching is on by default. Set `cache` on `Stagehand.create()` to change it for the instance, or override it per call:
Locator-scoped extractions, including calls with `locator` or `ignoreLocators`, bypass the server-side cache and report `metadata.cache.status` as `DISABLED`.
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
// Enable server-side caching for the entire instance
const browser = await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY });
const stagehand = await Stagehand.create({
browser,
cache: true,
});
// Or disable it for a single call
const result = await stagehand.extract(
"extract the repository name",
z.object({ name: z.string() }),
{ cache: false },
);
// Cache status travels on the result metadata
console.log(result.metadata.cache.status); // "HIT", "MISS", or "DISABLED"
```
```python theme={null}
import os
from stagehand import Stagehand, browserbase
# Enable server-side caching for the entire instance
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
cache=True,
)
# Or disable it for a single call
result = await stagehand.extract(
"extract the repository name",
Repository,
cache=False,
)
# Cache status travels on the result metadata
print(result.metadata.cache.status) # "HIT", "MISS", or "DISABLED"
```
```go theme={null}
// Enable server-side caching for the entire instance
apiKey := os.Getenv("BROWSERBASE_API_KEY")
instanceCache := stagehand.CacheEnabled(true)
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Cache: &instanceCache,
})
if err != nil {
return err
}
// Or disable it for a single call
off := stagehand.CacheEnabled(false)
result, err := stagehand.Extract[repository](ctx, client, "extract the repository name", &stagehand.StagehandClientExtractOptions{
Cache: &off,
})
if err != nil {
return err
}
// Cache status travels on the result metadata
fmt.Println(result.Metadata.Cache.Status) // "HIT", "MISS", or "DISABLED"
```
### Targeted extract
Pass a page locator to `extract` to target a specific element on the page.
This helps reduce the context passed to the LLM, optimizing token usage/speed and improving accuracy.
```typescript theme={null}
const page = await stagehand.browser.context.activePage();
const { data: tableData } = await stagehand.extract(
"Extract the values of the third row",
z.object({
values: z.array(z.string()),
}),
{
locator: page.locator("xpath=/html/body/div/table/"),
},
);
```
```python theme={null}
class TableRow(BaseModel):
values: list[str]
page = await stagehand.browser.context.active_page()
table_data = (await stagehand.extract(
"Extract the values of the third row",
TableRow,
locator=page.locator("xpath=/html/body/div/table/"),
)).data
```
```go theme={null}
type tableRow struct {
Values []string `json:"values"`
}
tableData, err := stagehand.Extract[tableRow](
ctx,
client,
"Extract the values of the third row",
&stagehand.StagehandClientExtractOptions{
Page: page,
Locator: page.Locator("xpath=/html/body/div/table/"),
},
)
```
You can also exclude specific nodes (including their descendant nodes) with ignored locators.
```typescript theme={null}
const page = await stagehand.browser.context.activePage();
const { data: article } = await stagehand.extract(
"extract the article title and body",
z.object({
title: z.string(),
body: z.string(),
}),
{
ignoreLocators: [
page.locator(".ad"),
page.locator(".newsletter-modal"),
page.locator("nav.related-posts"),
],
},
);
```
```python theme={null}
class Article(BaseModel):
title: str
body: str
page = await stagehand.browser.context.active_page()
article = (await stagehand.extract(
"extract the article title and body",
Article,
ignore_locators=[
page.locator(".ad"),
page.locator(".newsletter-modal"),
page.locator("nav.related-posts"),
],
)).data
```
```go theme={null}
type article struct {
Title string `json:"title"`
Body string `json:"body"`
}
extracted, err := stagehand.Extract[article](
ctx,
client,
"extract the article title and body",
&stagehand.StagehandClientExtractOptions{
Page: page,
IgnoreLocators: []*stagehand.PageLocator{
page.Locator(".ad"),
page.Locator(".newsletter-modal"),
page.Locator("nav.related-posts"),
},
},
)
```
`ignoreLocators` remove each resolved locator target and its descendants from the snapshot. A locator without `nth` removes all matching targets; a locator with `nth` removes only that indexed match. The `locator` option scopes extraction to one resolved subtree, using `nth` when present.
Scoped `extract` currently supports CSS and XPath locators. `text=` locators are supported by locator methods, but not yet by extract snapshot scoping.
### Visual extract
Turn the screenshot option on when the extraction needs visual information from the current viewport in addition to the page accessibility tree.
```typescript theme={null}
const { data: saleBadge } = await stagehand.extract(
"extract the text shown in the visible sale badge",
z.object({
text: z.string(),
}),
{
screenshot: true,
},
);
```
```python theme={null}
class SaleBadge(BaseModel):
text: str
sale_badge = (await stagehand.extract(
"extract the text shown in the visible sale badge",
SaleBadge,
screenshot=True,
)).data
```
```go theme={null}
type saleBadge struct {
Text string `json:"text"`
}
screenshot := true
saleBadgeData, err := stagehand.Extract[saleBadge](
ctx,
client,
"extract the text shown in the visible sale badge",
&stagehand.StagehandClientExtractOptions{
ExtractOptions: stagehand.ExtractOptions{Screenshot: &screenshot},
},
)
```
The screenshot option captures the current viewport, not the full page. Visual extractions always bypass the server-side cache, because a cache key is built from DOM state and cannot represent the pixels the model saw.
## Best practices
### Extract with context
You can provide additional context to your schema to help the model extract the data more accurately.
```typescript theme={null}
const { data } = await stagehand.extract(
"Extract ALL the apartment listings and their details, including address, price, and square feet.",
z.object({
apartments: z.array(
z.object({
address: z.string().describe("the address of the apartment"),
price: z.string().describe("the price of the apartment"),
squareFeet: z.string().describe("the square footage of the apartment"),
}),
),
}),
);
```
```python theme={null}
from pydantic import BaseModel, Field
class Apartment(BaseModel):
address: str = Field(description="the address of the apartment")
price: str = Field(description="the price of the apartment")
square_feet: str = Field(description="the square footage of the apartment")
class Apartments(BaseModel):
apartments: list[Apartment]
result = await stagehand.extract(
(
"Extract ALL the apartment listings and their details, "
"including address, price, and square feet."
),
Apartments,
)
```
```go theme={null}
type apartment struct {
Address string `json:"address" jsonschema:"description=the address of the apartment"`
Price string `json:"price" jsonschema:"description=the price of the apartment"`
SquareFeet string `json:"square_feet" jsonschema:"description=the square footage of the apartment"`
}
type apartments struct {
Apartments []apartment `json:"apartments"`
}
// Optional JSON Schema constraints live in jsonschema struct tags.
extracted, err := stagehand.Extract[apartments](
ctx,
client,
"Extract ALL the apartment listings and their details, including address, price, and square feet.",
nil,
)
```
### Link extraction
To extract links or URLs, define the relevant field as a URL type.
Here is how an `extract` call might look for extracting a link or URL. This also works for image links.
```typescript theme={null}
const { data } = await stagehand.extract(
"extract the link to the 'contact us' page",
// note the usage of z.url() for URL validation
z.object({ contactLink: z.url() }),
);
console.log("the link to the contact us page is: ", data.contactLink);
```
```python theme={null}
from pydantic import AnyUrl, BaseModel
class ContactLink(BaseModel):
# note the usage of AnyUrl for URL validation
contact_link: AnyUrl
result = await stagehand.extract(
"extract the link to the 'contact us' page",
ContactLink,
)
print("the link to the contact us page is: ", result.data.contact_link)
```
```go theme={null}
type contactLink struct {
ContactLink string `json:"contact_link" jsonschema:"format=uri"`
}
// The jsonschema tag adds the "format": "uri" constraint.
extracted, err := stagehand.Extract[contactLink](
ctx,
client,
"extract the link to the 'contact us' page",
nil,
)
if err != nil {
return err
}
fmt.Println("the link to the contact us page is: ", extracted.Data.ContactLink)
```
Inside Stagehand, extracting links works by asking the LLM to select an ID. Stagehand looks up that ID in a mapping of IDs to URLs. When logging the LLM trace, you should expect to see IDs. The actual URLs will be included in the final result.
## Troubleshooting
**Problem**: `extract()` returns empty or incomplete data
**Solutions**:
* **Check your instruction clarity:** Make sure your instruction is specific and describes exactly what data you want to extract
* **Verify the data exists:** Use `observe()` first to confirm the data is present on the page
* **Wait for dynamic content:** If the page loads content dynamically, wait for it before extracting
**Solution: Wait for content before extracting**
```typescript theme={null}
// waitForSelector reports whether the selector matched, so check it
// before extracting: a timeout resolves to false without throwing.
const listingReady = await page.waitForSelector(".product-listing");
if (!listingReady) {
throw new Error("timed out waiting for .product-listing");
}
const { data } = await stagehand.extract(
"extract all product names and prices",
z.object({
products: z.array(z.object({
name: z.string(),
price: z.string(),
})),
}),
);
```
```python theme={null}
# wait_for_selector reports whether the selector matched, so check it
# before extracting: a timeout returns False without raising.
listing_ready = await page.wait_for_selector(".product-listing")
if not listing_ready:
raise TimeoutError("timed out waiting for .product-listing")
class Product(BaseModel):
name: str
price: str
class Products(BaseModel):
products: list[Product]
result = await stagehand.extract(
"extract all product names and prices",
Products,
)
```
```go theme={null}
// WaitForSelector returns (matched, error), so check matched before
// extracting: a timeout reports false without returning an error.
matched, err := page.WaitForSelector(ctx, ".product-listing", nil)
if err != nil {
return err
}
if !matched {
return fmt.Errorf("timed out waiting for .product-listing")
}
type product struct {
Name string `json:"name"`
Price string `json:"price"`
}
type products struct {
Products []product `json:"products"`
}
extracted, err := stagehand.Extract[products](
ctx,
client,
"extract all product names and prices",
nil,
)
if err != nil {
return err
}
fmt.Println("extracted", len(extracted.Data.Products), "products")
```
**Problem**: Getting schema validation errors or type mismatches
**Solutions**:
* **Use optional fields:** Make fields optional if the data might not always be present
* **Use flexible types:** Consider using a string instead of a number for prices that might include currency symbols
* **Add descriptions:** Describe each field to help the model understand its requirements
**Solution: More flexible schema**
```typescript theme={null}
const schema = z.object({
price: z.string().describe("price including currency symbol, e.g., '$19.99'"),
availability: z.string().optional().describe("stock status if available"),
rating: z.number().optional(),
});
```
```python theme={null}
class ProductDetails(BaseModel):
price: str = Field(description="price including currency symbol, e.g., '$19.99'")
availability: str | None = Field(default=None, description="stock status if available")
rating: float | None = None
```
```go theme={null}
// Optional fields are pointers, and descriptions live in the JSON Schema
type productDetails struct {
Price string `json:"price" jsonschema:"description=price including currency symbol such as $19.99"`
Availability *string `json:"availability,omitempty" jsonschema:"description=stock status if available"`
Rating *float64 `json:"rating,omitempty"`
}
```
**Problem**: Extraction results vary between runs
**Solutions**:
* **Be more specific in instructions:** Instead of "extract prices", use "extract the numerical price value for each item"
* **Use context in schema descriptions:** Add field descriptions to guide the model
* **Combine with observe:** Use `observe()` to understand the page structure first
**Solution: Validate with observe first**
```typescript theme={null}
// First observe to understand the page structure
const { data: elements } = await stagehand.observe("find all product listings");
console.log("Found elements:", elements.map(e => e.description));
// Then extract with specific targeting
const { data } = await stagehand.extract(
"extract name and price from each product listing shown on the page",
z.object({
products: z.array(z.object({
name: z.string().describe("the product title or name"),
price: z.string().describe("the price as displayed, including currency"),
})),
}),
);
```
```python theme={null}
# First observe to understand the page structure
observed = await stagehand.observe("find all product listings")
print("Found elements:", [element.description for element in observed.data])
# Then extract with specific targeting
class Product(BaseModel):
name: str = Field(description="the product title or name")
price: str = Field(description="the price as displayed, including currency")
class Products(BaseModel):
products: list[Product]
result = await stagehand.extract(
"extract name and price from each product listing shown on the page",
Products,
)
```
```go theme={null}
// First observe to understand the page structure
instruction := "find all product listings"
observed, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
for _, element := range observed.Data {
fmt.Println("Found element:", element.Description)
}
// Then extract with specific targeting
type product struct {
Name string `json:"name"`
Price string `json:"price"`
}
type products struct {
Products []product `json:"products"`
}
extracted, err := stagehand.Extract[products](
ctx,
client,
"extract name and price from each product listing shown on the page",
nil,
)
if err != nil {
return err
}
for _, item := range extracted.Data.Products {
fmt.Println(item.Name, item.Price)
}
```
**Problem**: Extraction is slow or timing out
**Solutions**:
* **Reduce scope:** Extract smaller chunks of data in multiple calls rather than everything at once
* **Use targeted instructions:** Be specific about which part of the page to focus on
* **Consider pagination:** For large datasets, extract one page at a time
* **Increase timeout:** Use the timeout option for complex extractions
**Solution: Break down large extractions**
```typescript theme={null}
// Instead of extracting everything at once
const allData = [];
const pageNumbers = [1, 2, 3, 4, 5];
for (const pageNum of pageNumbers) {
await stagehand.act(`navigate to page ${pageNum}`);
const { data } = await stagehand.extract(
"extract product data from the current page only",
z.object({
products: z.array(z.object({
name: z.string(),
price: z.number(),
})),
}),
{ timeout: 60000 }, // 60 second timeout
);
allData.push(...data.products);
}
```
```python theme={null}
# Instead of extracting everything at once
all_data = []
page_numbers = [1, 2, 3, 4, 5]
for page_num in page_numbers:
await stagehand.act(f"navigate to page {page_num}")
result = await stagehand.extract(
"extract product data from the current page only",
Products,
timeout=60000, # 60 second timeout
)
all_data.extend(result.data.products)
```
```go theme={null}
type product struct {
Name string `json:"name"`
Price float64 `json:"price"`
}
type products struct {
Products []product `json:"products"`
}
// Instead of extracting everything at once
var allData []product
timeout := 60000.0 // 60 second timeout
for pageNum := 1; pageNum <= 5; pageNum++ {
if _, err := client.Act(ctx, stagehand.ActInstruction(fmt.Sprintf("navigate to page %d", pageNum)), nil); err != nil {
return err
}
extracted, err := stagehand.Extract[products](
ctx,
client,
"extract product data from the current page only",
&stagehand.StagehandClientExtractOptions{
ExtractOptions: stagehand.ExtractOptions{Timeout: &timeout},
},
)
if err != nil {
return err
}
allData = append(allData, extracted.Data.Products...)
}
```
## Next steps
Execute actions efficiently
Analyze pages and preview actions
# Observe
Source: https://docs.stagehand.dev/v4/basics/observe
Discover and plan executable actions on any web page
## What is `observe()`?
```typescript theme={null}
await stagehand.observe("find the login button");
```
```python theme={null}
await stagehand.observe("find the login button")
```
```go theme={null}
instruction := "find the login button"
if _, err := client.Observe(ctx, &instruction, nil); err != nil {
return err
}
```
`observe()` discovers actionable elements on a page and returns structured actions you can execute or validate before acting. Use it to explore pages, plan multi-step workflows, cache actions, and validate elements before acting.
## Why use `observe()`?
Discover what's possible on a page: find buttons, forms, links, and interactive elements
Map out multi-step workflows by discovering all required actions upfront
Store discovered actions to skip LLM calls and speed up repeated workflows
Verify elements exist and check their properties before performing critical actions
## Using `observe()`
Use `observe()` to discover actionable elements on a page. Here's how to find a button:
```typescript theme={null}
const [page] = await browser.context.pages();
if (!page) {
throw new Error("Stagehand initialized without an active page");
}
await page.goto("https://example.com");
const { data: actions } = await stagehand.observe("find the learn more button");
```
```python theme={null}
page = (await browser.context.pages())[0]
if page is None:
raise RuntimeError("Stagehand initialized without an active page")
await page.goto("https://example.com")
result = await stagehand.observe("find the learn more button")
```
```go theme={null}
page, err := browserContext.ActivePage(ctx)
if err != nil {
return err
}
if page == nil {
return errors.New("Stagehand initialized without an active page")
}
if _, err := page.Goto(ctx, "https://example.com", nil); err != nil {
return err
}
instruction := "find the learn more button"
result, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
fmt.Println("found", len(result.Data), "actions")
```
**iFrame and Shadow DOM support** Stagehand automatically handles iFrame traversal and shadow DOM elements without requiring additional configuration.
| Use Case | Example instruction |
| - | - |
| Find buttons | `find the submit button` |
| Locate forms | `find all input fields in the form` |
| Discover links | `find navigation links` |
| Identify tables | `find the pricing table` |
| Map workflows | `find all checkout steps` |
| Validate elements | `find the delete account button` |
### Return value of `observe()`
When you use `observe()`, Stagehand returns a result whose `data` field is a list of `Action` objects, alongside `metadata` carrying the action ID and server-side cache status. Each `Action` can be passed straight to [`act()`](/v4/basics/act) for deterministic replay:
```typescript theme={null}
[
{
description: 'Learn more button',
method: 'click',
arguments: [],
selector: 'xpath=/html[1]/body[1]/shadow-demo[1]//div[1]/button[1]'
}
]
```
```python theme={null}
[
Action(
description="Learn more button",
method="click",
arguments=[],
selector="xpath=/html[1]/body[1]/shadow-demo[1]//div[1]/button[1]",
)
]
```
```go theme={null}
clickMethod := "click"
actions := []stagehand.Action{
{
Description: "Learn more button",
Method: &clickMethod,
Arguments: []string{},
Selector: "xpath=/html[1]/body[1]/shadow-demo[1]//div[1]/button[1]",
},
}
fmt.Println(actions[0].Description, *actions[0].Method, actions[0].Selector)
```
Use specific, descriptive instructions.
```typescript theme={null}
// Clear and specific
await stagehand.observe("find the primary call-to-action button in the hero section");
await stagehand.observe("find all input fields in the checkout form");
await stagehand.observe("find the delete account button in settings");
```
```python theme={null}
# Clear and specific
await stagehand.observe(
"find the primary call-to-action button in the hero section"
)
await stagehand.observe("find all input fields in the checkout form")
await stagehand.observe("find the delete account button in settings")
```
```go theme={null}
// Clear and specific
hero := "find the primary call-to-action button in the hero section"
if _, err := client.Observe(ctx, &hero, nil); err != nil {
return err
}
fields := "find all input fields in the checkout form"
if _, err := client.Observe(ctx, &fields, nil); err != nil {
return err
}
deleteButton := "find the delete account button in settings"
if _, err := client.Observe(ctx, &deleteButton, nil); err != nil {
return err
}
```
Avoid vague or data-oriented queries.
```typescript theme={null}
// Too vague
await stagehand.observe("find buttons");
// Use extract() for data instead
await stagehand.observe("what is the page title?");
```
```python theme={null}
# Too vague
await stagehand.observe("find buttons")
# Use extract() for data instead
await stagehand.observe("what is the page title?")
```
```go theme={null}
// Too vague
vague := "find buttons"
if _, err := client.Observe(ctx, &vague, nil); err != nil {
return err
}
// Use Extract() for data instead
title := "what is the page title?"
if _, err := client.Observe(ctx, &title, nil); err != nil {
return err
}
```
## Advanced configuration
You can pass additional options to configure the model, timeout, locator scope, ignored page regions, and placeholder variables:
```typescript theme={null}
const page = await stagehand.browser.context.activePage();
// Custom model configuration
const { data: actions } = await stagehand.observe("find navigation links", {
model: {
modelName: "openai/gpt-5.6-luna",
apiKey: process.env.OPENAI_API_KEY,
},
timeout: 30000,
locator: page.locator("xpath=//header"), // Focus on specific area
});
```
```python theme={null}
import os
from stagehand import ModelConfig
page = await stagehand.browser.context.active_page()
# Custom model configuration
result = await stagehand.observe(
"find navigation links",
model=ModelConfig(
model_name="openai/gpt-5.6-luna",
api_key=os.environ["OPENAI_API_KEY"],
),
timeout=30000,
locator=page.locator("xpath=//header"), # Focus on specific area
)
```
```go theme={null}
// Custom model configuration
modelAPIKey := os.Getenv("OPENAI_API_KEY")
model := stagehand.ModelConfig{
ModelName: "openai/gpt-5.6-luna",
APIKey: &modelAPIKey,
}
timeout := 30000.0
instruction := "find navigation links"
result, err := client.Observe(ctx, &instruction, &stagehand.StagehandClientObserveOptions{
Page: page,
Model: &model,
Timeout: &timeout,
Locator: page.Locator("xpath=//header"), // Focus on specific area
})
if err != nil {
return err
}
fmt.Println("found", len(result.Data), "navigation links")
```
You can also exclude specific nodes, including their descendant nodes, with ignored locators.
```typescript theme={null}
const page = await stagehand.browser.context.activePage();
const { data: actions } = await stagehand.observe("find the main call-to-action buttons", {
ignoreLocators: [
page.locator("xpath=//aside[contains(@class, 'promo-rail')]"),
page.locator("xpath=//div[@id='floating-chat-launcher']"),
page.locator("xpath=//section[@aria-label='recommended articles']"),
],
});
```
```python theme={null}
page = await stagehand.browser.context.active_page()
result = await stagehand.observe(
"find the main call-to-action buttons",
ignore_locators=[
page.locator("xpath=//aside[contains(@class, 'promo-rail')]"),
page.locator("xpath=//div[@id='floating-chat-launcher']"),
page.locator("xpath=//section[@aria-label='recommended articles']"),
],
)
```
```go theme={null}
instruction := "find the main call-to-action buttons"
result, err := client.Observe(ctx, &instruction, &stagehand.StagehandClientObserveOptions{
Page: page,
IgnoreLocators: []*stagehand.PageLocator{
page.Locator("xpath=//aside[contains(@class, 'promo-rail')]"),
page.Locator("xpath=//div[@id='floating-chat-launcher']"),
page.Locator("xpath=//section[@aria-label='recommended articles']"),
},
})
if err != nil {
return err
}
fmt.Println("found", len(result.Data), "call-to-action buttons")
```
`ignoreLocators` remove each resolved locator target and its descendants from the snapshot. A locator without `nth` removes all matching targets; a locator with `nth` removes only that indexed match. The `locator` option scopes observation to one resolved subtree, using `nth` when present.
Scoped `observe` currently supports CSS and XPath locators. `text=` locators are supported by locator methods, but not yet by observe snapshot scoping.
### Validate then act with variables
For login and other safety-sensitive flows, use `observe()` to discover candidate actions, validate them, and then execute them. When you pass variables, `observe()` returns `%variableName%` placeholders in the suggested action arguments instead of raw secret values, so no secret ever reaches the model.
```typescript theme={null}
const { data: actions } = await stagehand.observe("find the login form fields", {
variables: {
username: { value: "user@example.com", description: "The login email" },
password: { value: process.env.USER_PASSWORD, description: "The login password" },
},
});
const emailField = actions.find((action) => action.arguments?.includes("%username%"));
const passwordField = actions.find((action) => action.arguments?.includes("%password%"));
if (emailField && passwordField) {
// Replay the observed actions, resolving placeholders locally
await stagehand.act(emailField, { variables: { username: "user@example.com" } });
await stagehand.act(passwordField, { variables: { password: process.env.USER_PASSWORD } });
}
```
```python theme={null}
observed = await stagehand.observe(
"find the login form fields",
variables={
"username": {"value": "user@example.com", "description": "The login email"},
"password": {
"value": os.environ["USER_PASSWORD"],
"description": "The login password",
},
},
)
email_field = next(
(a for a in observed.data if "%username%" in (a.arguments or [])), None
)
password_field = next(
(a for a in observed.data if "%password%" in (a.arguments or [])), None
)
if email_field is not None and password_field is not None:
# Replay the observed actions, resolving placeholders locally
await stagehand.act(email_field, variables={"username": "user@example.com"})
await stagehand.act(
password_field, variables={"password": os.environ["USER_PASSWORD"]}
)
```
```go theme={null}
usernameDescription := "The login email"
passwordDescription := "The login password"
password := os.Getenv("USER_PASSWORD")
instruction := "find the login form fields"
result, err := client.Observe(ctx, &instruction, &stagehand.StagehandClientObserveOptions{
ObserveOptions: stagehand.ObserveOptions{
Variables: stagehand.Variables{
"username": stagehand.DescribedVariable(stagehand.DescribedVariableValue{
Value: stagehand.StringVariable("user@example.com"),
Description: &usernameDescription,
}),
"password": stagehand.DescribedVariable(stagehand.DescribedVariableValue{
Value: stagehand.StringVariable(password),
Description: &passwordDescription,
}),
},
},
})
if err != nil {
return err
}
var emailField, passwordField *stagehand.Action
for i, action := range result.Data {
if slices.Contains(action.Arguments, "%username%") {
emailField = &result.Data[i]
}
if slices.Contains(action.Arguments, "%password%") {
passwordField = &result.Data[i]
}
}
if emailField != nil && passwordField != nil {
// Replay the observed actions, resolving placeholders locally
if _, err := client.Act(ctx, stagehand.ObservedAction(*emailField), &stagehand.StagehandClientActOptions{
ActOptions: stagehand.ActOptions{
Variables: stagehand.Variables{
"username": stagehand.PrimitiveVariable(stagehand.StringVariable("user@example.com")),
},
},
}); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ObservedAction(*passwordField), &stagehand.StagehandClientActOptions{
ActOptions: stagehand.ActOptions{
Variables: stagehand.Variables{
"password": stagehand.PrimitiveVariable(stagehand.StringVariable(password)),
},
},
}); err != nil {
return err
}
}
```
`act()` supports the same variables option with either input form. Pass an instruction when you want Stagehand to locate the field for you, or pass the observed `Action` to replay a known target, in both cases keeping the value out of the prompt.
### Server-side caching
`cache` requires a Browserbase browser and a Browserbase API key. It has no effect on local browsers.
When running on Browserbase, Stagehand can cache `observe()` results server-side. Repeated calls with the same inputs return instantly without consuming LLM tokens. Caching is on by default. Set `cache` on the constructor to change it for the instance, or override it per call:
Locator-scoped observations, including calls with `locator` or `ignoreLocators`, bypass the server-side cache and report `metadata.cache.status` as `DISABLED`.
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
// Enable server-side caching for the entire instance
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
});
const stagehand = await Stagehand.create({
browser,
cache: true,
});
// Or disable it for a single call
const { data: actions } = await stagehand.observe("find the login button", { cache: false });
```
```python theme={null}
import os
from stagehand import Stagehand, browserbase
# Enable server-side caching for the entire instance
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
)
stagehand = await Stagehand.create(
browser=browser,
cache=True,
)
# Or disable it for a single call
result = await stagehand.observe("find the login button", cache=False)
```
```go theme={null}
// Enable server-side caching for the entire instance
apiKey := os.Getenv("BROWSERBASE_API_KEY")
instanceCache := stagehand.CacheEnabled(true)
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{APIKey: apiKey})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Cache: &instanceCache,
})
// Or disable it for a single call
off := stagehand.CacheEnabled(false)
instruction := "find the login button"
result, err := client.Observe(ctx, &instruction, &stagehand.StagehandClientObserveOptions{
ObserveOptions: stagehand.ObserveOptions{Cache: &off},
})
if err != nil {
return err
}
fmt.Println(result.Metadata.Cache.Status) // "HIT", "MISS", or "DISABLED"
```
Cache status is reported on the result metadata, alongside the observed actions. You can also see it in the [Browserbase session replay dashboard](https://docs.browserbase.com/features/observability#stagehand) or in the session logs at debug level.
### Using with custom pages
Stagehand v4 drives the browser directly over the Chrome DevTools Protocol, so there is no Puppeteer, Playwright, or Patchright page interop. Instead, target any page Stagehand manages by passing the `page` option:
```typescript theme={null}
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
});
const stagehand = await Stagehand.create({ browser });
// Open a second page in the same browser context
const productsPage = await browser.context.newPage("https://www.example.com/products");
// Use observe with that specific page
const { data: actions } = await stagehand.observe("find all product cards", {
page: productsPage,
});
```
```python theme={null}
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
)
stagehand = await Stagehand.create(browser=browser)
# Open a second page in the same browser context
products_page = await browser.context.new_page("https://www.example.com/products")
# Use observe with that specific page
result = await stagehand.observe(
"find all product cards",
page=products_page,
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{APIKey: apiKey})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{Browser: browser})
if err != nil {
return err
}
browserContext, err := browser.Context()
if err != nil {
return err
}
// Open a second page in the same browser context
productsPage, err := browserContext.NewPage(ctx, "https://www.example.com/products")
if err != nil {
return err
}
// Use Observe with that specific page
instruction := "find all product cards"
result, err := client.Observe(ctx, &instruction, &stagehand.StagehandClientObserveOptions{
Page: productsPage,
})
if err != nil {
return err
}
fmt.Println("found", len(result.Data), "product cards")
```
This works with:
* **Active page:** omit `page` and Stagehand uses the active page (default)
* **Existing pages:** index into the list of context pages
* **New pages:** create one on the context
* **Existing browsers:** attach to a browser you already run with `localBrowser.connect()`
See the full `Stagehand` reference for detailed parameter documentation, return values, and advanced examples.
## Best practices
### Plan then execute
Discover all actions once, then feed each one back to `act()`. Passing an action rather than a string skips inference, so the loop below makes one model call for the whole form instead of one per field.
```typescript theme={null}
const { data: formFields } = await stagehand.observe("find all form input fields");
for (const field of formFields) {
// No LLM call: Stagehand replays the observed action
await stagehand.act(field);
}
```
```python theme={null}
observed = await stagehand.observe("find all form input fields")
for field in observed.data:
# No LLM call: Stagehand replays the observed action
await stagehand.act(field)
```
```go theme={null}
instruction := "find all form input fields"
observed, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
for _, field := range observed.Data {
// No LLM call: Stagehand replays the observed action
if _, err := client.Act(ctx, stagehand.ObservedAction(field), nil); err != nil {
return err
}
}
```
Complete guide to planning actions with `observe()`.
### Scope extractions
Use `observe()` to find a container, then create a page locator from the observed selector and pass that locator to `extract()`. Extraction then sees only that subtree instead of the whole page, so token usage falls in proportion to how much of the page you exclude.
```typescript theme={null}
const PricingSchema = z.object({
tiers: z.array(
z.object({
name: z.string(),
price: z.string(),
}),
),
});
const { data: tables } = await stagehand.observe("find the pricing table");
const [table] = tables;
const page = await stagehand.browser.context.activePage();
const { data: pricing } = await stagehand.extract(
"extract all pricing tiers",
PricingSchema,
{ locator: page.locator(table.selector) },
);
for (const tier of pricing.tiers) {
console.log(tier.name, tier.price);
}
```
```python theme={null}
class PricingTier(BaseModel):
name: str
price: str
class Pricing(BaseModel):
tiers: list[PricingTier]
observed = await stagehand.observe("find the pricing table")
page = await stagehand.browser.context.active_page()
pricing = (await stagehand.extract(
"extract all pricing tiers",
Pricing,
locator=page.locator(observed.data[0].selector),
)).data
for tier in pricing.tiers:
print(tier.name, tier.price)
```
```go theme={null}
type pricingTier struct {
Name string `json:"name"`
Price string `json:"price"`
}
type pricingTiers struct {
Tiers []pricingTier `json:"tiers"`
}
instruction := "find the pricing table"
observed, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
if len(observed.Data) == 0 {
return errors.New("pricing table not found")
}
pricing, err := stagehand.Extract[pricingTiers](
ctx,
client,
"extract all pricing tiers",
&stagehand.StagehandClientExtractOptions{
Page: page,
Locator: page.Locator(observed.Data[0].Selector),
},
)
if err != nil {
return err
}
for _, tier := range pricing.Data.Tiers {
fmt.Println(tier.Name, tier.Price)
}
```
Learn how to use `observe()` with `extract()` for precise data extraction.
### Validate before acting
Check elements exist and verify their properties before performing critical operations.
```typescript theme={null}
const { data: buttons } = await stagehand.observe("find the delete account button");
const [deleteButton] = buttons;
if (deleteButton?.method === "click") {
await stagehand.act(deleteButton);
} else {
throw new Error("Delete button not found");
}
```
```python theme={null}
observed = await stagehand.observe("find the delete account button")
delete_button = observed.data[0] if observed.data else None
if delete_button is not None and delete_button.method == "click":
await stagehand.act(delete_button)
else:
raise RuntimeError("Delete button not found")
```
```go theme={null}
instruction := "find the delete account button"
observed, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
if len(observed.Data) == 0 || observed.Data[0].Method == nil || *observed.Data[0].Method != "click" {
return errors.New("Delete button not found")
}
if _, err := client.Act(ctx, stagehand.ObservedAction(observed.Data[0]), nil); err != nil {
return err
}
```
Learn how to execute observed actions reliably.
### Cache observed actions
Store and reuse observed actions to eliminate redundant LLM calls. Build a simple in-process cache:
```typescript theme={null}
const actionCache = new Map();
async function cachedObserve(instruction: string) {
if (actionCache.has(instruction)) {
return actionCache.get(instruction)!;
}
const { data: actions } = await stagehand.observe(instruction);
actionCache.set(instruction, actions);
return actions;
}
```
```python theme={null}
action_cache: dict[str, list[Action]] = {}
async def cached_observe(instruction: str) -> list[Action]:
if instruction in action_cache:
return action_cache[instruction]
observed = await stagehand.observe(instruction)
action_cache[instruction] = observed.data
return observed.data
```
```go theme={null}
actionCache := map[string][]stagehand.Action{}
cachedObserve := func(ctx context.Context, instruction string) ([]stagehand.Action, error) {
if cached, ok := actionCache[instruction]; ok {
return cached, nil
}
observed, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return nil, err
}
actionCache[instruction] = observed.Data
return observed.Data, nil
}
```
Learn advanced caching techniques and patterns for optimal performance.
## Troubleshooting
**Problem**: `observe()` returns an empty list
**Solutions**:
* Verify the element exists on the page
* Use more specific instructions (e.g., "find the blue submit button" instead of "find button")
* Ensure page has fully loaded before calling `observe()`
* Set the log level to `debug` in your Stagehand configuration to inspect detection behavior
```typescript theme={null}
// Check page state before observing
const [page] = await browser.context.pages();
if (!page) {
throw new Error("Stagehand initialized without an active page");
}
await page.waitForLoadState("domcontentloaded");
const { data: actions } = await stagehand.observe("find the submit button");
if (actions.length === 0) {
console.log("No elements found, trying alternative instruction");
const { data: altActions } = await stagehand.observe("find the button at the bottom of the form");
}
```
```python theme={null}
# Check page state before observing
page = (await browser.context.pages())[0]
if page is None:
raise RuntimeError("Stagehand initialized without an active page")
await page.wait_for_load_state("domcontentloaded")
observed = await stagehand.observe("find the submit button")
if not observed.data:
print("No elements found, trying alternative instruction")
alt = await stagehand.observe(
"find the button at the bottom of the form"
)
```
```go theme={null}
// Check page state before observing
page, err := browserContext.ActivePage(ctx)
if err != nil {
return err
}
if page == nil {
return errors.New("Stagehand initialized without an active page")
}
if err := page.WaitForLoadState(ctx, stagehand.LoadStateDOMContentLoaded, nil); err != nil {
return err
}
instruction := "find the submit button"
observed, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
if len(observed.Data) == 0 {
fmt.Println("No elements found, trying alternative instruction")
alt := "find the button at the bottom of the form"
if _, err := client.Observe(ctx, &alt, nil); err != nil {
return err
}
}
```
**Problem**: Descriptions or selectors don't match actual elements
**Solutions**:
* Use more capable models, and check [model evals](https://stagehand.dev/evals) for recommendations
* Provide more context in your instruction (e.g., "find the submit button in the checkout form")
* Set the log level to `debug` in your Stagehand configuration to inspect LLM reasoning
```typescript theme={null}
// More specific instructions improve accuracy
// Instead of:
await stagehand.observe("find the button");
// Use context:
await stagehand.observe("find the red 'Delete' button in the user settings panel");
```
```python theme={null}
# More specific instructions improve accuracy
# Instead of:
await stagehand.observe("find the button")
# Use context:
await stagehand.observe(
"find the red 'Delete' button in the user settings panel"
)
```
```go theme={null}
// More specific instructions improve accuracy
// Instead of:
vague := "find the button"
if _, err := client.Observe(ctx, &vague, nil); err != nil {
return err
}
// Use context:
specific := "find the red 'Delete' button in the user settings panel"
if _, err := client.Observe(ctx, &specific, nil); err != nil {
return err
}
```
**Problem**: The `method` field has an unexpected value
**Solutions**:
* Validate the method before using it
* Check [supported actions](/v4/basics/act) for valid method names
* Reach for a [locator](/v4/reference/locator) when you want to call a specific method instead of trusting the suggestion
```typescript theme={null}
const { data: actions } = await stagehand.observe("find the submit button");
const [action] = actions;
// Validate method before acting
const validMethods = ["click", "fill", "type", "press"];
if (action && validMethods.includes(action.method || "")) {
await stagehand.act(action);
} else {
console.warn(`Unexpected method: ${action?.method}`);
}
```
```python theme={null}
observed = await stagehand.observe("find the submit button")
action = observed.data[0] if observed.data else None
# Validate method before acting
valid_methods = {"click", "fill", "type", "press"}
if action is not None and action.method in valid_methods:
await stagehand.act(action)
else:
print(f"Unexpected method: {action.method if action else None}")
```
```go theme={null}
instruction := "find the submit button"
observed, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
var action *stagehand.Action
if len(observed.Data) > 0 {
action = &observed.Data[0]
}
method := ""
if action != nil && action.Method != nil {
method = *action.Method
}
// Validate method before acting
validMethods := []string{"click", "fill", "type", "press"}
if action != nil && slices.Contains(validMethods, method) {
if _, err := client.Act(ctx, stagehand.ObservedAction(*action), nil); err != nil {
return err
}
} else {
fmt.Println("Unexpected method:", method)
}
```
## Next steps
Use `act()` to execute discovered actions reliably.
Combine `observe()` with `extract()` for precise data extraction.
Build action caches to eliminate redundant LLM calls.
Full `Stagehand` reference with detailed parameter documentation.
# WebMCP
Source: https://docs.stagehand.dev/v4/basics/webmcp
List and invoke tools registered by the current web page
## What is WebMCP?
Some pages expose their own capabilities as callable tools rather than making you drive their UI. WebMCP is the browser API for discovering those tools and invoking them directly, so a checkout flow that would otherwise take six clicks becomes one call with typed input.
`page.tools()` returns the tools the current page has registered. Each tool carries a name, a description, and a JSON Schema for its input, and each one can be invoked and awaited.
```typescript theme={null}
const tools = await page.tools();
const [checkout] = tools;
const invocation = await checkout.invoke({ input: { quantity: 2 } });
const response = await invocation.result();
console.log(response.status, response.output);
```
```python theme={null}
tools = await page.tools()
checkout = tools[0]
invocation = await checkout.invoke(input={"quantity": 2})
response = await invocation.result()
print(response.status, response.output)
```
```go theme={null}
tools, err := page.Tools(ctx, nil)
if err != nil {
return err
}
checkout := tools[0]
invocation, err := checkout.Invoke(ctx, stagehand.WebMCPInput{"quantity": 2})
if err != nil {
return err
}
response, err := invocation.Result(ctx, nil)
if err != nil {
return err
}
fmt.Println(response.Status, response.Output)
```
WebMCP tools are registered *by the page*. That makes them distinct from tools you wire into a model yourself: you do not define them, you discover whatever the site chose to publish.
## Browser support
WebMCP requires a Chromium build with the WebMCP features enabled. Stagehand ships this flag as one of the default launch flags for every local browser it starts:
```
--enable-features=WebMCPTesting,DevToolsWebMCPSupport
```
Stagehand can only set launch flags on browsers it launches. If you attach to a browser you started yourself, or you strip Stagehand's default flags on a local launch, start Chrome with `--enable-features=WebMCPTesting,DevToolsWebMCPSupport` or `page.tools()` will return an empty list.
## Listing tools
`page.tools()` takes a snapshot of what the page currently exposes. Call it again after navigating or after the page mounts new UI; the result is not live.
```typescript theme={null}
// Wait up to 3 seconds for the page to register its tools
const tools = await page.tools({ timeout: 3000 });
for (const tool of tools) {
console.log(tool.name, tool.description);
console.log(tool.inputSchema); // JSON Schema for invoke() input
console.log(tool.annotations); // { readOnly?, untrustedContent?, autosubmit? }
console.log(tool.frameId); // the frame that registered the tool
}
```
```python theme={null}
# Wait up to 3 seconds for the page to register its tools
tools = await page.tools(timeout=3000)
for tool in tools:
print(tool.name, tool.description)
print(tool.input_schema) # JSON Schema for invoke() input
print(tool.annotations) # read_only / untrusted_content / autosubmit
print(tool.frame_id) # the frame that registered the tool
```
```go theme={null}
// Wait up to 3 seconds for the page to register its tools
timeout := 3000.0
tools, err := page.Tools(ctx, &stagehand.WebMCPToolsOptions{Timeout: timeout})
if err != nil {
return err
}
for _, tool := range tools {
descriptor := tool.Descriptor()
fmt.Println(descriptor.Name, descriptor.Description)
fmt.Println(descriptor.InputSchema) // JSON Schema for Invoke input
fmt.Println(descriptor.Annotations) // ReadOnly / UntrustedContent / Autosubmit
fmt.Println(descriptor.FrameID) // the frame that registered the tool
}
```
The listing timeout defaults to 1000 ms. Tools declared in iframes are included, each tagged with the `frameId` that registered it.
| Annotation | Meaning |
| - | - |
| `readOnly` | The tool does not mutate state, so it is safe to call speculatively |
| `untrustedContent` | The output contains page-controlled text; do not feed it to a model as if it were trusted |
| `autosubmit` | Invoking the tool submits something on the user's behalf |
Annotations are hints from the page, not guarantees enforced by the browser. Treat `untrustedContent` output as data, never as instructions.
## Invoking tools
Invoking is two steps: `invoke()` hands the call to the browser and returns immediately with a handle, then `result()` waits for the terminal response. Splitting them means a long-running tool does not block you, and you can cancel while it is in flight.
```typescript theme={null}
const [search] = await page.tools();
// Returns as soon as Chrome accepts the invocation
const invocation = await search.invoke({ input: { query: "wireless mouse" } });
console.log(invocation.invocationId, invocation.toolName, invocation.input);
// Blocks until the tool reaches a terminal state
const response = await invocation.result({ timeout: 30000 });
```
```python theme={null}
tools = await page.tools()
search = tools[0]
# Returns as soon as Chrome accepts the invocation
invocation = await search.invoke(input={"query": "wireless mouse"})
print(invocation.invocation_id, invocation.tool_name, invocation.input)
# Blocks until the tool reaches a terminal state
response = await invocation.result(timeout=30000)
```
```go theme={null}
tools, err := page.Tools(ctx, nil)
if err != nil {
return err
}
search := tools[0]
// Returns as soon as Chrome accepts the invocation
invocation, err := search.Invoke(ctx, stagehand.WebMCPInput{"query": "wireless mouse"})
if err != nil {
return err
}
descriptor := invocation.Descriptor()
fmt.Println(descriptor.InvocationID, descriptor.ToolName, descriptor.Input)
// Blocks until the tool reaches a terminal state
resultTimeout := 30000.0
response, err := invocation.Result(ctx, &stagehand.WebMCPResultOptions{Timeout: &resultTimeout})
if err != nil {
return err
}
fmt.Println(response.Status, response.Output)
```
Omitting the input sends an empty object. Validate your input against the tool's `inputSchema` before invoking if you want a clear failure locally rather than an `Error` response from the page.
## Handling results
A terminal response reports one of three statuses:
| `status` | Meaning | Where to look |
| - | - | - |
| `Completed` | The tool finished | `output` |
| `Canceled` | Cancellation was honored | neither |
| `Error` | The tool failed | `errorText`, and `exception` when the page threw |
```typescript theme={null}
const response = await invocation.result();
switch (response.status) {
case "Completed":
console.log("output:", response.output);
break;
case "Canceled":
console.log("canceled before it finished");
break;
case "Error":
console.error(response.errorText, response.exception);
break;
}
```
```python theme={null}
response = await invocation.result()
if response.status == "Completed":
print("output:", response.output)
elif response.status == "Canceled":
print("canceled before it finished")
else:
print(response.error_text, response.exception)
```
```go theme={null}
response, err := invocation.Result(ctx, nil)
if err != nil {
return err
}
switch response.Status {
case stagehand.WebMCPInvocationStatusCompleted:
// Decode the JSON output into a type you choose
type searchOutput struct {
Results []string `json:"results"`
}
output, err := stagehand.WebMCPOutputAs[searchOutput](response)
if err != nil {
return err
}
fmt.Println("output:", output.Results)
case stagehand.WebMCPInvocationStatusCanceled:
fmt.Println("canceled before it finished")
case stagehand.WebMCPInvocationStatusError:
fmt.Println(response.ErrorText, response.Exception)
}
```
`result()` caches the terminal response, so calling it twice is cheap and returns the same value. A timeout or transport failure is not cached and can be retried.
### Canceling an invocation
```typescript theme={null}
const invocation = await slowTool.invoke({ input: {} });
// Ask the page to stop. The terminal status is still whatever Chrome reports.
await invocation.cancel();
const response = await invocation.result();
console.log(response.status); // often "Canceled", but the tool may have finished first
```
```python theme={null}
invocation = await slow_tool.invoke(input={})
# Ask the page to stop. The terminal status is still whatever Chrome reports.
await invocation.cancel()
response = await invocation.result()
print(response.status) # often "Canceled", but the tool may have finished first
```
```go theme={null}
invocation, err := slowTool.Invoke(ctx, nil)
if err != nil {
return err
}
// Ask the page to stop. The terminal status is still whatever Chrome reports.
if err := invocation.Cancel(ctx); err != nil {
return err
}
response, err := invocation.Result(ctx, nil)
if err != nil {
return err
}
fmt.Println(response.Status) // often "Canceled", but the tool may have finished first
```
Cancellation is a request, not a guarantee. Chrome decides the terminal status, so always read it from `result()` rather than assuming the invocation stopped.
## Timeouts
| Call | Default | Purpose |
| - | - | - |
| listing tools | 1000 ms | How long to wait for the page to register its tools |
| awaiting a result | none | How long to wait for a terminal response; waits indefinitely when unset |
Raise the listing timeout on pages that register tools after an async bootstrap. Set a result timeout whenever a tool could hang, so your automation fails loudly instead of stalling.
## Combining with the primitives
WebMCP and the primitives solve different problems, and mixing them is normal: use a tool where the page gives you one, and fall back to [`observe()`](/v4/basics/observe) and [`act()`](/v4/basics/act) where it does not.
```typescript theme={null}
const tools = await page.tools();
const addToCart = tools.find((tool) => tool.name === "addToCart");
if (addToCart) {
// Deterministic, typed, no model call
const invocation = await addToCart.invoke({ input: { sku: "ABC-123", quantity: 1 } });
await invocation.result();
} else {
// The page publishes no tool for this, so drive the UI
await stagehand.act("add the item to the cart");
}
```
```python theme={null}
tools = await page.tools()
add_to_cart = next((tool for tool in tools if tool.name == "addToCart"), None)
if add_to_cart is not None:
# Deterministic, typed, no model call
invocation = await add_to_cart.invoke(input={"sku": "ABC-123", "quantity": 1})
await invocation.result()
else:
# The page publishes no tool for this, so drive the UI
await stagehand.act("add the item to the cart")
```
```go theme={null}
tools, err := page.Tools(ctx, nil)
if err != nil {
return err
}
var addToCart *stagehand.WebMCPTool
for _, tool := range tools {
if tool.Descriptor().Name == "addToCart" {
addToCart = tool
break
}
}
if addToCart != nil {
// Deterministic, typed, no model call
invocation, err := addToCart.Invoke(ctx, stagehand.WebMCPInput{"sku": "ABC-123", "quantity": 1})
if err != nil {
return err
}
if _, err := invocation.Result(ctx, nil); err != nil {
return err
}
} else {
// The page publishes no tool for this, so drive the UI
if _, err := client.Act(ctx, stagehand.ActInstruction("add the item to the cart"), nil); err != nil {
return err
}
}
```
A tool invocation costs no LLM tokens and cannot pick the wrong element, so prefer one over an `act()` call whenever the page offers it.
## API reference
Discover page-provided tools with `Page.tools()`.
Inspect, invoke, await, and cancel `WebMCPTool` and `WebMCPInvocation` objects.
## Next steps
Discover what a page can do when it publishes no tools
Execute a single action with natural language
Pull typed, structured data off any page
Launch flags, browser factories, and attaching over CDP
# Caching actions
Source: https://docs.stagehand.dev/v4/best-practices/caching
Cache actions automatically to reduce costs and improve performance
Stagehand caches `act()`, `observe()`, and `extract()` results server-side to reduce LLM costs and speed up your automations. Browserbase manages the cache, so there is nothing to install, no cache files to manage, and no state to keep in sync across machines.
***
## Browserbase cache
Browserbase Cache is a managed, server-side caching layer inside the Stagehand API. It is on by default: Browserbase caches eligible `act()`, `observe()`, and `extract()` calls on its servers, and repeated calls with the same inputs return instantly without consuming any LLM tokens. Set `cache: false` to turn it off. Some calls are not eligible, see [limitations](#limitations).
Browserbase builds the cache key from the instruction, page content, and the options you pass. It deliberately leaves out model configuration, so switching models does not invalidate your cache. On a cache hit, the server returns the response directly with no LLM inference and no token cost. Check out the [Browserbase blog](https://www.browserbase.com/blog/stagehand-caching) for more details on how it works under the hood.
Caching requires a Browserbase browser and the Browserbase API key you passed to `browserbase.launch()`. With a local browser there is no Browserbase session to key against, so the `cache` option has no effect and every call runs inference.
### Default behavior
Stagehand enables caching by default, so a Browserbase browser caches every eligible call with no extra configuration. Pass `cache` on `Stagehand.create()` to change that for the instance: `false` opts out, and an object tunes the threshold.
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
const browser = await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY });
// Caching is on by default, so no cache option is needed here
const stagehand = await Stagehand.create({
browser,
});
const page = await browser.context.activePage();
await page.goto("https://example.com");
// Cached after the hit-count threshold is met
await stagehand.act("click the login button");
```
```python theme={null}
import os
from stagehand import Stagehand, browserbase
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
# Caching is on by default, so no cache option is needed here
stagehand = await Stagehand.create(
browser=browser,
)
page = await browser.context.active_page()
await page.goto("https://example.com")
# Cached after the hit-count threshold is met
await stagehand.act("click the login button")
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
// Caching is on by default, so no cache option is needed here
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
})
if err != nil {
return err
}
browserContext, err := browser.Context()
if err != nil {
return err
}
page, err := browserContext.ActivePage(ctx)
if err != nil {
return err
}
if page == nil {
return errors.New("Stagehand has no active page")
}
if _, err := page.Goto(ctx, "https://example.com", nil); err != nil {
return err
}
// Cached after the hit-count threshold is met
if _, err := client.Act(ctx, stagehand.ActInstruction("click the login button"), nil); err != nil {
return err
}
```
### Disabling per call
Override the instance setting for a single call by passing the cache option:
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
const browser = await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY });
const stagehand = await Stagehand.create({
browser,
cache: true,
});
const page = await browser.context.activePage();
await page.goto("https://example.com");
// This call skips the cache
await stagehand.act("click the login button", { cache: false });
// This call uses the cache as normal
await stagehand.act("submit the form");
```
```python theme={null}
import os
from stagehand import Stagehand, browserbase
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
cache=True,
)
page = await browser.context.active_page()
await page.goto("https://example.com")
# This call skips the cache
await stagehand.act("click the login button", cache=False)
# This call uses the cache as normal
await stagehand.act("submit the form")
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
cache := stagehand.CacheEnabled(true)
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Cache: &cache,
})
if err != nil {
return err
}
// This call skips the cache
off := stagehand.CacheEnabled(false)
if _, err := client.Act(ctx, stagehand.ActInstruction("click the login button"), &stagehand.StagehandClientActOptions{
ActOptions: stagehand.ActOptions{Cache: &off},
}); err != nil {
return err
}
// This call uses the cache as normal
if _, err := client.Act(ctx, stagehand.ActInstruction("submit the form"), nil); err != nil {
return err
}
```
### Cache threshold
The threshold controls how many times Browserbase must see an identical result before the cache starts serving it. A higher threshold means Stagehand waits until it is confident the result is stable; a threshold of `1` starts serving hits after a single successful run.
Set it on `Stagehand.create()` to change the default for the instance, or per call to tune a single step. It overrides the threshold configured on your Browserbase project.
```typescript theme={null}
const browser = await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY });
const stagehand = await Stagehand.create({
browser,
cache: { threshold: 2 }, // Serve hits after two identical results
});
const companiesSchema = z.object({ companies: z.array(z.string()) });
// Prime this one step aggressively: the second call is a hit
const { data } = await stagehand.extract(
"Extract the names of the first five companies listed on the page",
companiesSchema,
{ cache: { threshold: 1 } },
);
console.log(data.companies);
```
```python theme={null}
import os
from pydantic import BaseModel
from stagehand import CacheOptions, Stagehand, browserbase
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
cache=CacheOptions(threshold=2), # Serve hits after two identical results
)
class Companies(BaseModel):
companies: list[str]
# Prime this one step aggressively: the second call is a hit
result = await stagehand.extract(
instruction="Extract the names of the first five companies listed on the page",
schema=Companies,
cache=CacheOptions(threshold=1),
)
print(result.data.companies)
```
```go theme={null}
type companies struct {
Companies []string `json:"companies"`
}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
instanceCache := stagehand.CacheWithThreshold(2) // Serve hits after two identical results
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Cache: &instanceCache,
})
if err != nil {
return err
}
// Prime this one step aggressively: the second call is a hit
eager := stagehand.CacheWithThreshold(1)
result, err := stagehand.Extract[companies](
ctx,
client,
"Extract the names of the first five companies listed on the page",
&stagehand.StagehandClientExtractOptions{
ExtractOptions: stagehand.ExtractOptions{Cache: &eager},
},
)
if err != nil {
return err
}
fmt.Println(result.Data.Companies)
```
### Inspecting cache status
Every result carries `metadata.cache`, so you can verify whether the cache served that result. The status is always present: `HIT`, `MISS`, or `DISABLED` when no cache lookup ran at all. A miss also carries a `missReason`, and a hit carries the tokens it saved.
```typescript theme={null}
const result = await stagehand.act("click the login button");
console.log(result.metadata.cache.status); // "HIT", "MISS", or "DISABLED"
```
```python theme={null}
result = await stagehand.act("click the login button")
print(result.metadata.cache.status) # "HIT", "MISS", or "DISABLED"
```
```go theme={null}
result, err := client.Act(ctx, stagehand.ActInstruction("click the login button"), nil)
if err != nil {
return err
}
fmt.Println(result.Metadata.Cache.Status) // "HIT", "MISS", or "DISABLED"
```
Cache behavior is also visible in the [Browserbase session replay dashboard](https://docs.browserbase.com/features/observability#stagehand) and in Stagehand's own logs at the `debug` level.
### Limitations
* The page URL factors into the cache key. If you run the action on a page with a dynamic URL, caching may not work as expected. Browserbase filters out certain query parameters like referral trackers and analytics, but not all of them yet.
* If the page content or structure changes, the action won't get a cache `HIT` and Stagehand calls the LLM. Subsequent actions will attempt to hit the resulting cache entry.
* Caching is best-effort. If the cache is unreachable, Stagehand falls back to normal inference rather than failing your run.
* Stagehand replays a cached `act()` result deterministically with self-healing turned off. If the recorded selector no longer resolves, Stagehand falls back to full inference.
* Stagehand does not cache calls that set `locator` or `ignoreLocators`. The cache contract uses unscoped requests as keys, so Stagehand skips both reads and writes and reports a `DISABLED` cache status, even when caching is enabled.
### Best practices
When targeting a specific part of a page, pass a locator to scope the accessibility tree snapshot to that container. This reduces token costs and speeds up inference.
Locator-scoped `act()`, `observe()`, and `extract()` calls currently bypass the server-side result cache, because the cache contract is keyed on unscoped requests. Their cache status is `DISABLED` even when instance-level caching is enabled.
```typescript theme={null}
async function example(stagehand: Stagehand) {
const page = await stagehand.browser.context.activePage();
await page.goto("https://www.google.com/search?q=browserbase");
await page.waitForLoadState("networkidle");
const result = await stagehand.observe("click the first search result", {
// Scope to the search results container so surrounding content
// stays out of the snapshot sent to the model.
locator: page.locator("#rcnt"),
});
}
```
```python theme={null}
async def example(stagehand: Stagehand) -> None:
page = await stagehand.browser.context.active_page()
await page.goto("https://www.google.com/search?q=browserbase")
await page.wait_for_load_state("networkidle")
result = await stagehand.observe(
instruction="click the first search result",
# Scope to the search results container so surrounding content
# stays out of the snapshot sent to the model.
locator=page.locator("#rcnt"),
)
```
```go theme={null}
func example(ctx context.Context, client *stagehand.Stagehand, page *stagehand.Page) error {
if _, err := page.Goto(ctx, "https://www.google.com/search?q=browserbase", nil); err != nil {
return err
}
if err := page.WaitForLoadState(ctx, stagehand.LoadStateNetworkIdle, nil); err != nil {
return err
}
// Scope to the search results container so surrounding content
// stays out of the snapshot sent to the model.
instruction := "click the first search result"
_, err := client.Observe(ctx, &instruction, &stagehand.StagehandClientObserveOptions{
Page: page,
Locator: page.Locator("#rcnt"),
})
return err
}
```
When you set `locator` or `ignoreLocators` on `act()`, `observe()`, or `extract()`, Stagehand skips server-side cache reads and writes for that call.
Variables keep a literal value out of the instruction you write. Stagehand sends the model the variable name and substitutes the real value into the resolved action right before it runs, so the value never reaches the model and the logged action keeps its `%placeholder%`.
Do not assume two runs with different values share a cache entry. The variables you pass travel to the cache service with the rest of the request, so a different value may produce different key data and miss. Because Stagehand transmits them, turn the `cache` option off on any call carrying a credential. See [prompting with variables](/v4/best-practices/prompting-best-practices) for the full caveat.
```typescript theme={null}
async function example(stagehand: Stagehand) {
const page = await stagehand.browser.context.activePage();
await page.goto("https://example.com/login");
await page.waitForLoadState("networkidle");
// The model only ever sees %email%, and the logged action keeps the placeholder
// A different email value travels with the request, so expect a MISS
await stagehand.act(
"type %email% into the Email address field",
{ variables: { email: "alice@example.com" } },
);
}
```
```python theme={null}
async def example(stagehand: Stagehand) -> None:
page = await stagehand.browser.context.active_page()
await page.goto("https://example.com/login")
await page.wait_for_load_state("networkidle")
# The model only ever sees %email%, and the logged action keeps the placeholder
# A different email value travels with the request, so expect a MISS
await stagehand.act(
"type %email% into the Email address field",
variables={"email": "alice@example.com"},
)
```
```go theme={null}
func example(ctx context.Context, client *stagehand.Stagehand, page *stagehand.Page) error {
if _, err := page.Goto(ctx, "https://example.com/login", nil); err != nil {
return err
}
if err := page.WaitForLoadState(ctx, stagehand.LoadStateNetworkIdle, nil); err != nil {
return err
}
// The model only ever sees %email%, and the logged action keeps the placeholder
// A different email value travels with the request, so expect a MISS
_, err := client.Act(ctx, stagehand.ActInstruction("type %email% into the Email address field"), &stagehand.StagehandClientActOptions{
ActOptions: stagehand.ActOptions{
Variables: stagehand.Variables{
"email": stagehand.PrimitiveVariable(stagehand.StringVariable("alice@example.com")),
},
},
})
return err
}
```
Small differences in runtime state produce different accessibility trees and therefore different cache keys. Keep your environment as deterministic as possible:
* **Fixed viewport size:** pin the viewport before your first action
* **Consistent user agent and locale:** set these in your browser launch options
* **Block noisy third-party requests:** analytics, A/B testing scripts, and ad trackers can inject DOM nodes that shift the cache key on every load
```typescript theme={null}
const page = await browser.context.activePage();
// Lock the viewport so the layout is identical across runs
await page.setViewportSize(1280, 720);
// Block third-party noise that pollutes the accessibility tree
await browser.context.setDomainPolicy({
blockedDomains: ["*.google-analytics.com", "*.doubleclick.net"],
});
```
```python theme={null}
page = await browser.context.active_page()
# Lock the viewport so the layout is identical across runs
await page.set_viewport_size(1280, 720)
# Block third-party noise that pollutes the accessibility tree
await browser.context.set_domain_policy(
DomainPolicy(blocked_domains=["*.google-analytics.com", "*.doubleclick.net"])
)
```
```go theme={null}
browserContext, err := browser.Context()
if err != nil {
return err
}
page, err := browserContext.ActivePage(ctx)
if err != nil {
return err
}
if page == nil {
return errors.New("Stagehand has no active page")
}
// Lock the viewport so the layout is identical across runs
if err := page.SetViewportSize(ctx, 1280, 720, nil); err != nil {
return err
}
// Block third-party noise that pollutes the accessibility tree
if err := browserContext.SetDomainPolicy(ctx, &stagehand.DomainPolicy{
BlockedDomains: []string{"*.google-analytics.com", "*.doubleclick.net"},
}); err != nil {
return err
}
```
The instruction string is part of the cache key. Even minor wording changes (synonyms, extra adjectives, punctuation) produce a new key and a cache miss.
* Anchor instructions to visible UI labels: `"click the Sign in button"` not `"click the button to log me in"`
* Keep instructions short and free of filler words
* Avoid instructions that contain runtime-variable text inline; use variables instead
```typescript theme={null}
// Good: anchored to the visible label, no variance
await stagehand.act("click the Sign in button");
// Bad: inline dynamic value creates a new cache key every time
await stagehand.act(`type ${email} into the Email address field`);
```
```python theme={null}
# Good: anchored to the visible label, no variance
await stagehand.act("click the Sign in button")
# Bad: inline dynamic value creates a new cache key every time
await stagehand.act(f"type {email} into the Email address field")
```
```go theme={null}
// Good: anchored to the visible label, no variance
if _, err := client.Act(ctx, stagehand.ActInstruction("click the Sign in button"), nil); err != nil {
return err
}
// Bad: inline dynamic value creates a new cache key every time
if _, err := client.Act(ctx, stagehand.ActInstruction(fmt.Sprintf("type %s into the Email address field", email)), nil); err != nil {
return err
}
```
Both server-side caching and the [Model Gateway](/v4/configuration/models#model-gateway) run off the same Browserbase session and the same API key. Using them together means one bill for inference, browsers, and cache, and no provider keys to rotate.
```typescript theme={null}
const browser = await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY });
const stagehand = await Stagehand.create({
browser,
model: { modelName: "openai/gpt-5.6-sol" }, // Routed through Model Gateway
cache: true, // Served by Browserbase Cache
});
```
```python theme={null}
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
model="openai/gpt-5.6-sol", # Routed through Model Gateway
cache=True, # Served by Browserbase Cache
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
// Routed through Model Gateway: no model API key
model := stagehand.ModelConfig{ModelName: "openai/gpt-5.6-sol"}
cache := stagehand.CacheEnabled(true) // Served by Browserbase Cache
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Model: &model,
Cache: &cache,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
# Cost optimization
Source: https://docs.stagehand.dev/v4/best-practices/cost-optimization
Minimize costs while maintaining automation performance
Cost optimization in Stagehand involves balancing LLM inference costs and browser infrastructure costs. This guide provides practical strategies to reduce your automation expenses.
## Quick wins
Start with these simple optimizations that can reduce costs:
### Use the right model for the job
Browserbase doesn't recommend using larger, more premium models for simple tasks. See the [evaluation results](https://stagehand.dev/evals) for model performance and cost comparisons across different task types.
Reach for a stronger model only on the calls that need it, with a per-call model override:
```typescript theme={null}
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
model: {
modelName: "google/gemini-3.8-flash",
},
});
const termsSchema = z.object({ summary: z.string() });
// One hard extraction gets the expensive model, everything else stays cheap
await stagehand.extract("summarize the contract terms", termsSchema, {
model: {
modelName: "anthropic/claude-sonnet-5",
apiKey: process.env.ANTHROPIC_API_KEY,
},
});
```
```python theme={null}
import os
from stagehand import ModelConfig, Stagehand, browserbase
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
model="google/gemini-3.8-flash",
model_api_key=os.environ["GOOGLE_GENERATIVE_AI_API_KEY"],
)
class Terms(BaseModel):
summary: str
# One hard extraction gets the expensive model, everything else stays cheap
await stagehand.extract(
instruction="summarize the contract terms",
schema=Terms,
model=ModelConfig(
model_name="anthropic/claude-sonnet-5",
api_key=os.environ["ANTHROPIC_API_KEY"],
),
)
```
```go theme={null}
type terms struct {
Summary string `json:"summary"`
}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
googleKey := os.Getenv("GOOGLE_GENERATIVE_AI_API_KEY")
cheapModel := stagehand.ModelConfig{
ModelName: "google/gemini-3.8-flash",
APIKey: &googleKey,
}
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Model: &cheapModel,
})
if err != nil {
return err
}
// One hard extraction gets the expensive model, everything else stays cheap
anthropicKey := os.Getenv("ANTHROPIC_API_KEY")
strongModel := stagehand.ModelConfig{
ModelName: "anthropic/claude-sonnet-5",
APIKey: &anthropicKey,
}
result, err := stagehand.Extract[terms](ctx, client, "summarize the contract terms", &stagehand.StagehandClientExtractOptions{
ExtractOptions: stagehand.ExtractOptions{Model: &strongModel},
})
if err != nil {
return err
}
fmt.Println(result.Data.Summary)
```
Choose the right LLM for your budget and accuracy requirements
See how different models perform on different tasks
### Implement caching
Enable server-side caching to eliminate redundant LLM calls. Turn on the `cache` option when initializing Stagehand:
```typescript theme={null}
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
cache: { threshold: 1 }, // Serve hits after one identical result
});
// First run: uses LLM inference and records the action
// Subsequent runs: reuses the cached action (no LLM cost)
await stagehand.act("Click the sign in button");
```
```python theme={null}
import os
from stagehand import CacheOptions, Stagehand, browserbase
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
cache=CacheOptions(threshold=1), # Serve hits after one identical result
)
# First run: uses LLM inference and records the action
# Subsequent runs: reuses the cached action (no LLM cost)
await stagehand.act("Click the sign in button")
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
cache := stagehand.CacheWithThreshold(1) // Serve hits after one identical result
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Cache: &cache,
})
if err != nil {
return err
}
// First run: uses LLM inference and records the action
// Subsequent runs: reuses the cached action (no LLM cost)
if _, err := client.Act(ctx, stagehand.ActInstruction("Click the sign in button"), nil); err != nil {
return err
}
```
Learn how the cache key is built and how to tune the hit-count threshold
### Optimize browser sessions
Reuse sessions when possible and set appropriate timeouts. See [Browser Configuration](/v4/configuration/browser) for details:
```typescript theme={null}
const stagehand = await Stagehand.create({
browser: await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
timeout: 1800, // 30 minutes instead of default 1 hour
keepAlive: true, // Keep session alive between tasks
}),
});
```
```python theme={null}
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
timeout=1800, # 30 minutes instead of default 1 hour
keep_alive=True, # Keep session alive between tasks
)
stagehand = await Stagehand.create(browser=browser)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
timeout := 1800.0 // 30 minutes instead of default 1 hour
keepAlive := true // Keep session alive between tasks
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
Timeout: &timeout,
KeepAlive: &keepAlive,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
Optimize Browserbase infrastructure costs and session management
## Advanced strategies
### Intelligent model switching
Automatically fall back to cheaper models for simple tasks. Escalate on `observe()` rather than `act()`: `observe()` only plans and never touches the page, so a retry cannot repeat a click, submit, or purchase that already landed before the error surfaced. The winning plan is then handed to `act()` exactly once.
```typescript theme={null}
// Use models from least to most expensive based on task complexity
// See stagehand.dev/evals for performance comparisons
async function smartAct(stagehand: Stagehand, prompt: string) {
const models = [
{ modelName: "google/gemini-3.8-flash", apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY },
{ modelName: "openai/gpt-5.4", apiKey: process.env.OPENAI_API_KEY },
] as const;
for (const model of models) {
// Planning is side-effect free, so escalating here is safe to repeat
const plan = await stagehand.observe(prompt, { model }).catch(() => null);
// An empty plan means the model found no matching action, so escalate too
if (!plan || plan.data.length === 0) {
console.log(`${model.modelName} failed, escalating...`);
continue;
}
// The page is touched once, by the first model that produced a plan
return await stagehand.act(plan.data[0], { model });
}
throw new Error(`No model could complete: ${prompt}`);
}
```
```python theme={null}
# Use models from least to most expensive based on task complexity
# See stagehand.dev/evals for performance comparisons
async def smart_act(stagehand: Stagehand, prompt: str):
models = [
{
"model_name": "google/gemini-3.8-flash",
"api_key": os.environ["GOOGLE_GENERATIVE_AI_API_KEY"],
},
{"model_name": "openai/gpt-5.4", "api_key": os.environ["OPENAI_API_KEY"]},
]
for model in models:
# Planning is side-effect free, so escalating here is safe to repeat
try:
plan = await stagehand.observe(instruction=prompt, model=model)
except Exception:
plan = None
# An empty plan means the model found no matching action, so escalate too
if plan is None or not plan.data:
print(f"{model['model_name']} failed, escalating...")
continue
# The page is touched once, by the first model that produced a plan
return await stagehand.act(plan.data[0], model=model)
raise RuntimeError(f"No model could complete: {prompt}")
```
```go theme={null}
// Use models from least to most expensive based on task complexity
// See stagehand.dev/evals for performance comparisons
func smartAct(ctx context.Context, client *stagehand.Stagehand, prompt string) (stagehand.ActResult, error) {
googleKey := os.Getenv("GOOGLE_GENERATIVE_AI_API_KEY")
openaiKey := os.Getenv("OPENAI_API_KEY")
models := []stagehand.ModelConfig{
stagehand.ModelConfig{
ModelName: "google/gemini-3.8-flash",
APIKey: &googleKey,
},
stagehand.ModelConfig{
ModelName: "openai/gpt-5.4",
APIKey: &openaiKey,
},
}
for i := range models {
// Planning is side-effect free, so escalating here is safe to repeat
plan, err := client.Observe(ctx, &prompt, &stagehand.StagehandClientObserveOptions{
ObserveOptions: stagehand.ObserveOptions{Model: &models[i]},
})
// An empty plan means the model found no matching action, so escalate too
if err != nil || len(plan.Data) == 0 {
fmt.Println("model failed, escalating...")
continue
}
// The page is touched once, by the first model that produced a plan
return client.Act(ctx, stagehand.ObservedAction(plan.Data[0]), &stagehand.StagehandClientActOptions{
ActOptions: stagehand.ActOptions{Model: &models[i]},
})
}
return stagehand.ActResult{}, fmt.Errorf("no model could complete: %s", prompt)
}
```
### Session pooling
Reuse browser sessions across multiple tasks:
```typescript theme={null}
class SessionManager {
// Store the in-flight promise, not the resolved instance: two callers racing
// on the same task type would otherwise each start a session and leak one
private sessions = new Map>();
getSession(taskType: string): Promise {
const existing = this.sessions.get(taskType);
if (existing) {
return existing;
}
const pending = (async () => {
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
});
return stagehand;
})().catch((error) => {
// Drop the failed attempt so the next caller retries instead of
// re-awaiting the same rejection forever
this.sessions.delete(taskType);
throw error;
});
this.sessions.set(taskType, pending);
return pending;
}
async closeAll(): Promise {
const pending = [...this.sessions.values()];
this.sessions.clear();
// Settle tolerantly: one failed init must not skip closing healthy sessions
const settled = await Promise.allSettled(pending);
const sessions = settled.flatMap((r) => (r.status === "fulfilled" ? [r.value] : []));
await Promise.all(sessions.map((s) => s.close()));
}
}
```
```python theme={null}
import os
import asyncio
class SessionManager:
def __init__(self) -> None:
# Store the in-flight task, not the resolved instance: two callers racing
# on the same task type would otherwise each start a session and leak one
self._sessions: dict[str, asyncio.Task[Stagehand]] = {}
async def get_session(self, task_type: str) -> Stagehand:
task = self._sessions.get(task_type)
if task is None:
task = asyncio.create_task(self._open_session())
self._sessions[task_type] = task
try:
return await task
except Exception:
# Drop the failed attempt so the next caller retries instead of
# re-awaiting the same rejection forever
self._sessions.pop(task_type, None)
raise
async def _open_session(self) -> Stagehand:
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(browser=browser)
return stagehand
async def close_all(self) -> None:
tasks = list(self._sessions.values())
self._sessions.clear()
# Settle tolerantly: one failed init must not skip closing healthy sessions
sessions = await asyncio.gather(*tasks, return_exceptions=True)
await asyncio.gather(*(s.close() for s in sessions if isinstance(s, Stagehand)))
```
```go theme={null}
type sessionManager struct {
mu sync.Mutex
sessions map[string]*stagehand.Stagehand
}
func (m *sessionManager) getSession(ctx context.Context, taskType string) (*stagehand.Stagehand, error) {
// The lock spans Init so two callers racing on the same task type cannot
// each start a session and leak one
m.mu.Lock()
defer m.mu.Unlock()
if existing, ok := m.sessions[taskType]; ok {
return existing, nil
}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return nil, err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
})
if err != nil {
// Release the browser, or the session bills until it times out. Close
// records its outcome once, so pass a context that outlives ctx:
// a canceled ctx would spend that one attempt on a failure.
closeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
return nil, errors.Join(err, browser.Close(closeCtx))
}
if m.sessions == nil {
m.sessions = map[string]*stagehand.Stagehand{}
}
m.sessions[taskType] = client
return client, nil
}
func (m *sessionManager) closeAll(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
var errs []error
for key, client := range m.sessions {
errs = append(errs, client.Close(ctx))
delete(m.sessions, key)
}
return errors.Join(errs...)
}
```
## Cost monitoring
Track your spending to identify optimization opportunities. See the [observability guide](/v4/configuration/observability) for detailed metrics.
Stagehand reports token counts, not dollars. Keep prompt and completion tokens separate: providers bill them at different rates, so a single blended rate over the total is wrong for every model. Multiply each count by the input and output rates on your provider's pricing page to turn these into currency.
```typescript theme={null}
// Monitor token usage
const metrics = await stagehand.metrics();
console.log(`Prompt tokens: ${metrics.totalPromptTokens}`);
console.log(`Completion tokens: ${metrics.totalCompletionTokens}`);
// Tokens served from your provider's prompt cache cost less
console.log(`Cached input tokens: ${metrics.totalCachedInputTokens}`);
```
```python theme={null}
# Monitor token usage
metrics = await stagehand.metrics()
print(f"Prompt tokens: {metrics.total_prompt_tokens}")
print(f"Completion tokens: {metrics.total_completion_tokens}")
# Tokens served from your provider's prompt cache cost less
print(f"Cached input tokens: {metrics.total_cached_input_tokens}")
```
```go theme={null}
// Monitor token usage
metrics, err := client.Metrics(ctx)
if err != nil {
return err
}
fmt.Printf("Prompt tokens: %.0f\n", metrics.TotalPromptTokens)
fmt.Printf("Completion tokens: %.0f\n", metrics.TotalCompletionTokens)
// Tokens served from your provider's prompt cache cost less
fmt.Printf("Cached input tokens: %.0f\n", metrics.TotalCachedInputTokens)
```
Monitor usage patterns and track costs in real-time
## Budget controls
Set spending limits to prevent unexpected costs:
```typescript theme={null}
class BudgetGuard {
private dailySpend = 0;
private spendDay = new Date().toDateString();
private maxDailyBudget: number;
constructor(maxDailyBudget: number = 25) {
this.maxDailyBudget = maxDailyBudget;
}
checkBudget(estimatedCost: number): void {
// Roll the window when the calendar day changes, otherwise the first day's
// total blocks the process for the rest of its life
const today = new Date().toDateString();
if (today !== this.spendDay) {
this.spendDay = today;
this.dailySpend = 0;
}
if (this.dailySpend + estimatedCost > this.maxDailyBudget) {
throw new Error(`Daily budget exceeded: $${this.maxDailyBudget}`);
}
this.dailySpend += estimatedCost;
}
}
```
```python theme={null}
from datetime import date
class BudgetGuard:
def __init__(self, max_daily_budget: float = 25) -> None:
self.daily_spend = 0.0
self.spend_day = date.today()
self.max_daily_budget = max_daily_budget
def check_budget(self, estimated_cost: float) -> None:
# Roll the window when the calendar day changes, otherwise the first day's
# total blocks the process for the rest of its life
today = date.today()
if today != self.spend_day:
self.spend_day = today
self.daily_spend = 0.0
if self.daily_spend + estimated_cost > self.max_daily_budget:
raise RuntimeError(f"Daily budget exceeded: ${self.max_daily_budget}")
self.daily_spend += estimated_cost
```
```go theme={null}
type budgetGuard struct {
mu sync.Mutex
dailySpend float64
spendDay string
maxDailyBudget float64
}
func newBudgetGuard(maxDailyBudget float64) *budgetGuard {
if maxDailyBudget == 0 {
maxDailyBudget = 25
}
return &budgetGuard{
maxDailyBudget: maxDailyBudget,
spendDay: time.Now().Format(time.DateOnly),
}
}
func (g *budgetGuard) checkBudget(estimatedCost float64) error {
g.mu.Lock()
defer g.mu.Unlock()
// Roll the window when the calendar day changes, otherwise the first day's
// total blocks the process for the rest of its life
if today := time.Now().Format(time.DateOnly); today != g.spendDay {
g.spendDay = today
g.dailySpend = 0
}
if g.dailySpend+estimatedCost > g.maxDailyBudget {
return fmt.Errorf("daily budget exceeded: $%.2f", g.maxDailyBudget)
}
g.dailySpend += estimatedCost
return nil
}
```
Using the [Model Gateway](/v4/configuration/models#model-gateway) puts inference, browsers, and caching on a single Browserbase bill, which makes spend easier to attribute and cap in one place.
## Related resources
Choose the right LLM for your budget and accuracy requirements
Reduce costs with smart action caching and observe patterns
Monitor usage patterns and track costs in real-time
Optimize Browserbase infrastructure costs and session management
# Deploying Stagehand
Source: https://docs.stagehand.dev/v4/best-practices/deployments
Deploy your AI agents and automations to the cloud
Browserbase recommends that you deploy Stagehand as a [Browserbase Function](https://docs.browserbase.com/platform/functions/quickstart). The Function runs next to its browser session on Browserbase, and it reads your API key from an encrypted project secret. You do not need a separate server, a Chrome binary, or a secrets store.
Stagehand also runs on other platforms. If Functions is not a good fit for you, see [Other deployment options](#other-deployment-options).
## Prerequisites
* A Browserbase API key from [Settings](https://www.browserbase.com/settings)
* Node.js 22.18 or later and pnpm
* The `browse` CLI:
```bash theme={null}
npm install -g browse
```
## Deploy a Stagehand Function
These steps start from a template project. The template contains a starter Function that you replace with your own Stagehand code before you publish.
```bash theme={null}
browse functions init my-stagehand-function
cd my-stagehand-function
```
The initializer installs Stagehand and creates a starter Stagehand Function in `index.ts`.
Stagehand needs its extension in the browser session, and a session cannot get an extension after it starts. Upload the extension one time for your project:
```bash theme={null}
browse cloud extensions upload node_modules/@browserbasehq/stagehand/dist/assets/stagehand-extension.zip
```
In `index.ts`, replace `your-extension-id` with the `id` from the output.
This is the starter Function in `index.ts`. Replace the template with the Stagehand code that you want to deploy. Keep the `defineFn` call. If you change the name `my-function`, use the new name in the steps that follow.
```typescript theme={null}
import { defineFn } from "@browserbasehq/sdk-functions";
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
import { z } from "zod/v4";
defineFn(
"my-function",
async (context) => {
const browser = await browserbase.connect({
// The local dev server has no secrets, so fall back to .env.
apiKey:
context.secrets.BROWSERBASE_API_KEY ?? process.env.BROWSERBASE_API_KEY!,
sessionId: context.session.id,
});
// In this example, Stagehand uses the Model Gateway where Browserbase charges for the tokens
const stagehand = await Stagehand.create({ browser });
const page = (await browser.context.activePage())!;
await page.goto("https://news.ycombinator.com");
const { data } = await stagehand.extract(
"Extract the top 3 stories with their rank, title, and link URL.",
z.object({
stories: z
.array(z.object({ rank: z.number(), title: z.string(), url: z.string() }))
.max(3),
}),
);
await stagehand.close();
return {
message: "Successfully fetched top Hacker News stories",
timestamp: new Date().toISOString(),
results: data.stories,
};
},
{
// Upload the Stagehand extension once, then paste its ID here:
// browse cloud extensions upload node_modules/@browserbasehq/stagehand/dist/assets/stagehand-extension.zip
sessionConfig: { extensionId: "your-extension-id" },
},
);
```
Stagehand doesn't need a model API key, and when you omit it, Stagehand uses the [Model Gateway](/v4/configuration/models#model-gateway). Browserbase charges for the tokens.
Export your key, then start the development server:
```bash theme={null}
export BROWSERBASE_API_KEY=your_browserbase_api_key
browse functions dev index.ts
```
In another terminal, invoke the Function by the name that you gave to `defineFn`:
```bash theme={null}
curl --request POST \
--url http://127.0.0.1:14113/v1/functions/my-function/invoke \
--header 'Content-Type: application/json' \
--data '{}'
```
```bash theme={null}
browse functions publish index.ts
```
The output contains `builtFunctions`. Copy the `id` of `my-function`. This is the Function ID.
You're looking for `builtFunctions[].id` for this step.
If the build fails, open the build in the Browserbase dashboard to see the build logs.
Create a project secret for your Browserbase API key. The name of the secret is the key in `context.secrets`:
```bash theme={null}
browse cloud secrets create BROWSERBASE_API_KEY --env BROWSERBASE_API_KEY
```
The CLI encrypts the value on your machine before it sends it. Copy the `id` of the secret, then attach it to the Function:
```bash theme={null}
browse functions secrets attach
browse functions secrets list
```
The `list` output must show the secret. Attached secrets take effect on the next invocation.
```bash theme={null}
browse functions invoke
```
The command waits for the invocation and prints its result. You can also invoke the Function from the Browserbase dashboard.
## Troubleshooting
* **`function or secret not found` when you attach a secret**: an ID is incorrect. Use `browse cloud secrets list` to verify your secret IDs and the Functions dashboard to verify your Function ID.
* **`Secret "BROWSERBASE_API_KEY" is not set`**: the secret is not attached to this Function. Run `browse functions secrets list ` to view attached secrets and `browse functions secrets attach ` to attach the missing secret.
## Features
* **No local browsers needed**: Browserbase provides the browser, so your Function ships without a Chrome binary.
* **Encrypted secrets**: your API key stays in a Browserbase project secret, and the Function reads it from `context.secrets`.
* **No model provider setup**: the Model Gateway picks a model for each call, and Browserbase charges for the tokens. You only need your Browserbase API key.
## Other deployment options
Run Stagehand on Browserbase inside a Vercel Function. This guide shows a minimal HTTP endpoint you can call directly or on a schedule.
Every request to this endpoint opens a Browserbase session and spends model tokens. Vercel serves production deployments publicly, so gate the handler before you deploy it. The handlers below reject any request that does not present `Authorization: Bearer $CRON_SECRET`, the same header Vercel sends on cron invocations.
To download and install Vercel CLI, run one of the following commands:
```bash theme={null}
pnpm i -g vercel
# npm i -g vercel
# yarn global add vercel
# bun add -g vercel
```
```text theme={null}
your-project/
api/
run.ts
package.json
tsconfig.json
vercel.json
```
```text theme={null}
your-project/
api/
run.py
requirements.txt
vercel.json
```
```text theme={null}
your-project/
api/
run.go
go.mod
vercel.json
```
Create the structure with:
```bash theme={null}
mkdir -p api
touch api/run.ts package.json vercel.json tsconfig.json
```
```bash theme={null}
mkdir -p api
touch api/run.py requirements.txt vercel.json
```
```bash theme={null}
mkdir -p api
touch api/run.go vercel.json
go mod init example.com/bb-stagehand-on-vercel
```
```typescript theme={null}
// api/run.ts
import type { VercelRequest, VercelResponse } from "@vercel/node";
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
import { z } from "zod/v4";
async function run(): Promise {
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY!,
region: "us-west-2",
browserSettings: {
blockAds: true,
},
});
const stagehand = await Stagehand.create({
browser,
model: {
modelName: "google/gemini-3.8-flash",
},
logging: { level: "warn", format: "json" },
});
try {
const page = await browser.context.activePage();
if (!page) throw new Error("Stagehand initialized without an active page");
await page.goto("https://www.stagehand.dev/");
await stagehand.act("click the evals button");
const { data } = await stagehand.extract(
"extract the fastest model",
z.object({ fastestModel: z.string() }),
);
return data.fastestModel;
} finally {
await stagehand.close();
}
}
export default async function handler(req: VercelRequest, res: VercelResponse): Promise {
const secret = process.env.CRON_SECRET;
if (!secret || req.headers.authorization !== `Bearer ${secret}`) {
res.status(401).json({ ok: false, error: "unauthorized" });
return;
}
try {
const data = await run();
res.status(200).json({ ok: true, data });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
res.status(500).json({ ok: false, error: msg });
}
}
```
```python theme={null}
# api/run.py
import json
import os
from http.server import BaseHTTPRequestHandler
import anyio
from pydantic import BaseModel
from stagehand import BrowserbaseBrowserSettings, Stagehand, StagehandClientLoggingConfig, browserbase
class FastestModel(BaseModel):
fastest_model: str
async def run() -> str:
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"], region="us-west-2", browser_settings=BrowserbaseBrowserSettings.model_validate({"block_ads": True}))
stagehand = await Stagehand.create(
browser=browser,
model="google/gemini-3.8-flash",
model_api_key=os.environ["GOOGLE_API_KEY"],
logging=StagehandClientLoggingConfig(level="warn", format="json"),
)
try:
page = await browser.context.active_page()
if page is None:
raise RuntimeError("Stagehand initialized without an active page")
await page.goto("https://www.stagehand.dev/")
await stagehand.act("click the evals button")
result = await stagehand.extract(
instruction="extract the fastest model",
schema=FastestModel,
)
return result.data.fastest_model
finally:
await stagehand.close()
class handler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
secret = os.environ.get("CRON_SECRET")
if not secret or self.headers.get("Authorization") != f"Bearer {secret}":
self._respond(401, {"ok": False, "error": "unauthorized"})
return
try:
data = anyio.run(run)
body = {"ok": True, "data": data}
status = 200
except Exception as error:
body = {"ok": False, "error": str(error)}
status = 500
self._respond(status, body)
# Vercel cron jobs invoke the function with GET.
do_GET = do_POST
def _respond(self, status: int, body: dict[str, object]) -> None:
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(body).encode())
```
```go theme={null}
// api/run.go
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
stagehand "github.com/browserbase/stagehand/packages/sdk-go/v4"
)
type fastestModel struct {
FastestModel string `json:"fastest_model"`
}
func Handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
secret := os.Getenv("CRON_SECRET")
if secret == "" || r.Header.Get("Authorization") != "Bearer "+secret {
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": "unauthorized"})
return
}
data, err := run(r.Context())
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": err.Error()})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "data": data})
}
func run(ctx context.Context) (result string, err error) {
apiKey := os.Getenv("BROWSERBASE_API_KEY")
modelAPIKey := os.Getenv("GOOGLE_API_KEY")
region := stagehand.BrowserbaseRegionUSWest2
blockAds := true
model := stagehand.ModelConfig{
ModelName: "google/gemini-3.8-flash",
APIKey: &modelAPIKey,
}
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
Region: ®ion,
BrowserSettings: &stagehand.BrowserbaseBrowserSettings{
BlockAds: &blockAds,
},
})
if err != nil {
return "", err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Model: &model,
Logging: &stagehand.StagehandClientLoggingConfig{
Level: stagehand.StagehandClientLogLevelWarn,
Format: stagehand.StagehandClientLogFormatJSON,
},
})
if err != nil {
return "", err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
browserContext, err := browser.Context()
if err != nil {
return "", err
}
page, err := browserContext.ActivePage(ctx)
if err != nil {
return "", err
}
if page == nil {
return "", errors.New("Stagehand initialized without an active page")
}
if _, err := page.Goto(ctx, "https://www.stagehand.dev/", nil); err != nil {
return "", err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("click the evals button"), nil); err != nil {
return "", err
}
extracted, err := stagehand.Extract[fastestModel](
ctx,
client,
"extract the fastest model",
nil,
)
if err != nil {
return "", err
}
return extracted.Data.FastestModel, nil
}
func main() {
http.HandleFunc("/api/run", Handler)
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
fmt.Println("listening on", port)
_ = http.ListenAndServe(":"+port, nil)
}
```
```json theme={null}
{
"name": "bb-stagehand-on-vercel",
"private": true,
"type": "module",
"engines": { "node": ">=22.18" },
"dependencies": {
"@browserbasehq/stagehand": "^4.0.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@vercel/node": "^3.2.20",
"typescript": "^5.2.2"
}
}
```
```text theme={null}
# requirements.txt
stagehand
anyio
```
```text theme={null}
// go.mod
module example.com/bb-stagehand-on-vercel
go 1.26.0
require github.com/browserbase/stagehand/packages/sdk-go/v4 v4.0.0
```
```json theme={null}
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"outDir": ".vercel/output/functions",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["api/**/*.ts"]
}
```
```text theme={null}
Not applicable. Python functions on Vercel need no compiler configuration;
declare your dependencies in requirements.txt instead.
```
```text theme={null}
Not applicable. Go functions on Vercel need no compiler configuration;
declare your dependencies in go.mod instead.
```
```json theme={null}
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"functions": {
"api/run.ts": {
"maxDuration": 60
}
}
}
```
```json theme={null}
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"functions": {
"api/run.py": {
"maxDuration": 60
}
}
}
```
```json theme={null}
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"functions": {
"api/run.go": {
"maxDuration": 60
}
}
}
```
See Vercel's [configuring functions](https://vercel.com/docs/functions/configuring-functions) docs for more details.
Link your local folder to a Vercel project before configuring environment variables:
```bash theme={null}
# authenticate if needed
vercel login
# link the current directory to a Vercel project (interactive)
vercel link
```
Never commit secrets. Add variables via the Vercel CLI, then read them in your handler and pass them where you launch the browser and create the Stagehand client. `CRON_SECRET` is the secret the handler compares against the `Authorization` header, and Vercel sends it automatically when a cron job invokes the function:
```bash theme={null}
vercel env add BROWSERBASE_API_KEY
# (and your model key if needed)
vercel env add GOOGLE_API_KEY
# the shared secret the handler requires on every request
# generate one with: openssl rand -hex 32
vercel env add CRON_SECRET
```
Using the [Model Gateway](/v4/configuration/models#model-gateway) means `BROWSERBASE_API_KEY` is the only secret you need to deploy.
See also: [Browser Configuration](/v4/configuration/browser) for details on required variables.
Replicate the Vercel environment locally to exercise your Function before deploying. Run from the project root.
```bash theme={null}
# ensure dependencies are installed
pnpm install
# start the local Vercel dev server
vercel dev --listen 5005
```
```bash theme={null}
# ensure dependencies are installed
pip install -r requirements.txt
# start the local Vercel dev server
vercel dev --listen 5005
```
```bash theme={null}
# ensure dependencies are installed
go mod tidy
# start the local Vercel dev server
vercel dev --listen 5005
```
```bash theme={null}
vercel
vercel --prod
```
Every request needs `Authorization: Bearer $CRON_SECRET`. Requests without it get a 401 and never reach Browserbase.
If the deployment also sits behind Vercel Deployment Protection, create a Protection Bypass for Automation so scripted callers can reach it:
1. Generate a 32-character secret (you can use `openssl rand -hex 16`)
2. Go to your project in Vercel
3. Navigate to Settings, then Deployment Protection
4. Add the secret to "Protection Bypass for Automation"
Then invoke the function with the bypass header:
```bash theme={null}
curl -X POST \
-H "Authorization: Bearer " \
-H "x-vercel-protection-bypass: " \
https:///api/run
```
Hit the same endpoint on a schedule by extending `vercel.json`. Cron invocations arrive as `GET` requests carrying `Authorization: Bearer $CRON_SECRET`, so the handler's secret check passes without extra configuration:
```json theme={null}
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"functions": {
"api/run.ts": {
"maxDuration": 60
}
},
"crons": [
{ "path": "/api/run", "schedule": "0 * * * *" }
]
}
```
```json theme={null}
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"functions": {
"api/run.py": {
"maxDuration": 60
}
},
"crons": [
{ "path": "/api/run", "schedule": "0 * * * *" }
]
}
```
```json theme={null}
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"functions": {
"api/run.go": {
"maxDuration": 60
}
},
"crons": [
{ "path": "/api/run", "schedule": "0 * * * *" }
]
}
```
**Features**
* **No local browsers needed** with a Browserbase browser. [Browserbase](https://www.browserbase.com/) provides the browsers, so your function ships without a Chrome binary.
* **Fast functionality**: Offload browser work to Browserbase and return JSON promptly.
* **Long-running tasks**: Raise `maxDuration` and/or consider Edge runtime limits depending on plan.
* **Cheap repeat runs**: Turn on [server-side caching](/v4/best-practices/caching) so scheduled invocations replay recorded actions instead of paying for inference every hour.
# Prompting best practices
Source: https://docs.stagehand.dev/v4/best-practices/prompting-best-practices
Write effective prompts for reliable Stagehand automation
Good prompts make Stagehand reliable. Bad prompts cause failures. Here's how to write prompts that work consistently.
## Act method
Use `act()` for single actions on web pages. Each action should be focused and clear.
```typescript theme={null}
// Good - Single, specific actions
await stagehand.act("click the 'Add to Cart' button");
await stagehand.act("type 'user@example.com' into the email field");
// Bad - Multiple actions combined
await stagehand.act("fill out the form and submit it");
await stagehand.act("login with credentials and navigate to dashboard");
```
```python theme={null}
# Good - Single, specific actions
await stagehand.act("click the 'Add to Cart' button")
await stagehand.act("type 'user@example.com' into the email field")
# Bad - Multiple actions combined
await stagehand.act("fill out the form and submit it")
await stagehand.act("login with credentials and navigate to dashboard")
```
```go theme={null}
// Good - Single, specific actions
if _, err := client.Act(ctx, stagehand.ActInstruction("click the 'Add to Cart' button"), nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("type 'user@example.com' into the email field"), nil); err != nil {
return err
}
// Bad - Multiple actions combined
if _, err := client.Act(ctx, stagehand.ActInstruction("fill out the form and submit it"), nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("login with credentials and navigate to dashboard"), nil); err != nil {
return err
}
```
### Use element types, not colors
Describe elements by their type and function rather than visual attributes like color.
```typescript theme={null}
// Good - Element types and descriptive text
await stagehand.act("click the 'Sign In' button");
await stagehand.act("type into the email input field");
// Bad - Color-based descriptions
await stagehand.act("click the blue button");
await stagehand.act("type into the white input");
```
```python theme={null}
# Good - Element types and descriptive text
await stagehand.act("click the 'Sign In' button")
await stagehand.act("type into the email input field")
# Bad - Color-based descriptions
await stagehand.act("click the blue button")
await stagehand.act("type into the white input")
```
```go theme={null}
// Good - Element types and descriptive text
if _, err := client.Act(ctx, stagehand.ActInstruction("click the 'Sign In' button"), nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("type into the email input field"), nil); err != nil {
return err
}
// Bad - Color-based descriptions
if _, err := client.Act(ctx, stagehand.ActInstruction("click the blue button"), nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("type into the white input"), nil); err != nil {
return err
}
```
### Use descriptive language
```typescript theme={null}
// Good - Clear element identification
await stagehand.act("click the 'Next' button at the bottom of the form");
await stagehand.act("type into the search bar at the top of the page");
// Bad - Vague descriptions
await stagehand.act("click next");
await stagehand.act("type into search");
```
```python theme={null}
# Good - Clear element identification
await stagehand.act("click the 'Next' button at the bottom of the form")
await stagehand.act("type into the search bar at the top of the page")
# Bad - Vague descriptions
await stagehand.act("click next")
await stagehand.act("type into search")
```
```go theme={null}
// Good - Clear element identification
if _, err := client.Act(ctx, stagehand.ActInstruction("click the 'Next' button at the bottom of the form"), nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("type into the search bar at the top of the page"), nil); err != nil {
return err
}
// Bad - Vague descriptions
if _, err := client.Act(ctx, stagehand.ActInstruction("click next"), nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("type into search"), nil); err != nil {
return err
}
```
### Choose the right action verbs
* **Click** for buttons, links, checkboxes
* **Type** for text inputs
* **Select** for dropdowns
* **Check/uncheck** for checkboxes
* **Upload** for file inputs
```typescript theme={null}
// Good
await stagehand.act("click the submit button");
await stagehand.act("select 'Option 1' from dropdown");
// Bad
await stagehand.act("click submit");
await stagehand.act("choose option 1");
```
```python theme={null}
# Good
await stagehand.act("click the submit button")
await stagehand.act("select 'Option 1' from dropdown")
# Bad
await stagehand.act("click submit")
await stagehand.act("choose option 1")
```
```go theme={null}
// Good
if _, err := client.Act(ctx, stagehand.ActInstruction("click the submit button"), nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("select 'Option 1' from dropdown"), nil); err != nil {
return err
}
// Bad
if _, err := client.Act(ctx, stagehand.ActInstruction("click submit"), nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("choose option 1"), nil); err != nil {
return err
}
```
### Protect sensitive data
Variables keep sensitive information out of prompts and logs. Stagehand shows the model only the variable name and its optional description, then substitutes the real value into the resolved action right before it runs, so the secret never reaches the model and never appears in the logged action.
Variables are part of the payload that builds the [cache key](/v4/best-practices/caching). Stagehand caches by default, so the variables you pass travel to the cache service with the rest of the request. Set `cache: false` on any call that carries a credential.
```typescript theme={null}
// Use variables for sensitive data
await stagehand.act("type %username% into the email field", {
variables: { username: "user@example.com" },
});
// v4 reads no environment variables, so read the secret yourself
await stagehand.act("type %password% into the password field", {
variables: { password: process.env.USER_PASSWORD },
});
```
```python theme={null}
# Use variables for sensitive data
await stagehand.act(
"type %username% into the email field",
variables={"username": "user@example.com"},
)
# v4 reads no environment variables, so read the secret yourself
await stagehand.act(
"type %password% into the password field",
variables={"password": os.environ["USER_PASSWORD"]},
)
```
```go theme={null}
// Use variables for sensitive data
if _, err := client.Act(ctx, stagehand.ActInstruction("type %username% into the email field"), &stagehand.StagehandClientActOptions{
ActOptions: stagehand.ActOptions{
Variables: stagehand.Variables{
"username": stagehand.PrimitiveVariable(stagehand.StringVariable("user@example.com")),
},
},
}); err != nil {
return err
}
// v4 reads no environment variables, so read the secret yourself
password := os.Getenv("USER_PASSWORD")
if _, err := client.Act(ctx, stagehand.ActInstruction("type %password% into the password field"), &stagehand.StagehandClientActOptions{
ActOptions: stagehand.ActOptions{
Variables: stagehand.Variables{
"password": stagehand.PrimitiveVariable(stagehand.StringVariable(password)),
},
},
}); err != nil {
return err
}
```
Set the log level to `off` in your Stagehand config to prevent secrets from appearing in logs.
## Extract method
Use `extract()` to pull structured data from pages. Define clear schemas and provide context.
### Schema best practices
Use descriptive field names, correct types, and detailed descriptions. Field descriptions provide context that helps the model understand exactly what to extract.
```typescript theme={null}
// Good - Descriptive names, correct types, and helpful descriptions
const { data: productData } = await stagehand.extract(
"Extract product information",
z.object({
productTitle: z.string().describe("The main product name displayed on the page"),
priceInDollars: z.number().describe("Current selling price as a number, without currency symbol"),
isInStock: z.boolean().describe("Whether the product is available for purchase"),
}),
);
// Bad - Generic names, wrong types, no descriptions
const { data } = await stagehand.extract(
"Get product details",
z.object({
name: z.string(), // Too generic, no context
price: z.string(), // Should be number
stock: z.string(), // Should be boolean, no context
}),
);
```
```python theme={null}
from pydantic import BaseModel, Field
# Good - Descriptive names, correct types, and helpful descriptions
class ProductData(BaseModel):
product_title: str = Field(description="The main product name displayed on the page")
price_in_dollars: float = Field(
description="Current selling price as a number, without currency symbol"
)
is_in_stock: bool = Field(description="Whether the product is available for purchase")
product_data = (await stagehand.extract(
instruction="Extract product information",
schema=ProductData,
)).data
# Bad - Generic names, wrong types, no descriptions
class Data(BaseModel):
name: str # Too generic, no context
price: str # Should be a number
stock: str # Should be a boolean, no context
data = (await stagehand.extract(instruction="Get product details", schema=Data)).data
```
```go theme={null}
// Good - Descriptive names, correct types, and helpful descriptions
type productData struct {
ProductTitle string `json:"product_title" jsonschema:"description=the main product name displayed on the page"`
PriceInDollars float64 `json:"price_in_dollars" jsonschema:"description=current selling price as a number without a currency symbol"`
IsInStock bool `json:"is_in_stock" jsonschema:"description=whether the product is available for purchase"`
}
// Bad - Generic names, wrong types, no descriptions
type data struct {
Name string `json:"name"`
Price string `json:"price"`
Stock string `json:"stock"`
}
```
### Use proper URL types
Type link fields as URLs so Stagehand resolves them to real addresses instead of the internal element IDs the model chooses.
```typescript theme={null}
// Good - Tells Stagehand to extract URLs
const { data } = await stagehand.extract(
"Extract navigation links",
z.object({
links: z.array(z.object({
text: z.string(),
url: z.url(), // Required for URL extraction
})),
}),
);
// Single URL extraction
const { data: contact } = await stagehand.extract(
"extract the contact page URL",
z.object({ contactUrl: z.url() }),
);
```
```python theme={null}
from pydantic import AnyUrl, BaseModel
# Good - Tells Stagehand to extract URLs
class Link(BaseModel):
text: str
url: AnyUrl # Required for URL extraction
class Links(BaseModel):
links: list[Link]
result = (await stagehand.extract(
instruction="Extract navigation links",
schema=Links,
)).data
# Single URL extraction
class ContactUrl(BaseModel):
contact_url: AnyUrl
contact = (await stagehand.extract(
instruction="extract the contact page URL",
schema=ContactUrl,
)).data
```
```go theme={null}
// Good - Tells Stagehand to extract URLs
type link struct {
Text string `json:"text"`
URL string `json:"url" jsonschema:"format=uri"`
}
type links struct {
Links []link `json:"links"`
}
extracted, err := stagehand.Extract[links](ctx, client, "Extract navigation links", nil)
if err != nil {
return err
}
for _, l := range extracted.Data.Links {
fmt.Println(l.Text, l.URL)
}
```
## Observe method
Use `observe()` to discover actionable elements before acting on them.
### Check elements first
Verify elements exist before taking action to avoid errors.
```typescript theme={null}
// Check for elements first
const { data: loginButtons } = await stagehand.observe("Find the login button");
if (loginButtons.length > 0) {
await page.locator(loginButtons[0].selector).click();
} else {
console.log("No login button found");
}
```
```python theme={null}
# Check for elements first
login_buttons = (await stagehand.observe(instruction="Find the login button")).data
if login_buttons:
await page.locator(login_buttons[0].selector).click()
else:
print("No login button found")
```
```go theme={null}
// Check for elements first
instruction := "Find the login button"
observed, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
if len(observed.Data) > 0 {
if err := page.Locator(observed.Data[0].Selector).Click(ctx, nil); err != nil {
return err
}
} else {
fmt.Println("No login button found")
}
```
### Be specific about element types
```typescript theme={null}
// Good - Specific element types
const { data: submitButtons } = await stagehand.observe("Find submit button in the form");
const { data: dropdowns } = await stagehand.observe("Find the state dropdown menu");
// Bad - Too vague
const { data: elements } = await stagehand.observe("Find submit stuff");
const { data: things } = await stagehand.observe("Find state selection");
```
```python theme={null}
# Good - Specific element types
submit_buttons = (await stagehand.observe(instruction="Find submit button in the form")).data
dropdowns = (await stagehand.observe(instruction="Find the state dropdown menu")).data
# Bad - Too vague
elements = (await stagehand.observe(instruction="Find submit stuff")).data
things = (await stagehand.observe(instruction="Find state selection")).data
```
```go theme={null}
// Good - Specific element types
submit := "Find submit button in the form"
submitButtons, err := client.Observe(ctx, &submit, nil)
if err != nil {
return err
}
fmt.Println(len(submitButtons.Data))
dropdown := "Find the state dropdown menu"
dropdowns, err := client.Observe(ctx, &dropdown, nil)
if err != nil {
return err
}
fmt.Println(len(dropdowns.Data))
// Bad - Too vague
vague := "Find submit stuff"
if _, err := client.Observe(ctx, &vague, nil); err != nil {
return err
}
alsoVague := "Find state selection"
if _, err := client.Observe(ctx, &alsoVague, nil); err != nil {
return err
}
```
## Sequencing multi-step work
Stagehand v4 has no autonomous agent, so multi-step flows are your control flow. That is a feature: you decide the order, the retries, and the stopping condition.
### Navigate first
Don't put navigation inside an instruction. Handle it separately with `goto`.
```typescript theme={null}
// Good - Navigate first, then act
await page.goto("https://amazon.com");
await stagehand.act("type 'wireless headphones' into the search box");
// Bad - Navigation inside the instruction
await stagehand.act("go to Amazon and search for headphones");
```
```python theme={null}
# Good - Navigate first, then act
await page.goto("https://amazon.com")
await stagehand.act("type 'wireless headphones' into the search box")
# Bad - Navigation inside the instruction
await stagehand.act("go to Amazon and search for headphones")
```
```go theme={null}
// Good - Navigate first, then act
if _, err := page.Goto(ctx, "https://amazon.com", nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("type 'wireless headphones' into the search box"), nil); err != nil {
return err
}
// Bad - Navigation inside the instruction
if _, err := client.Act(ctx, stagehand.ActInstruction("go to Amazon and search for headphones"), nil); err != nil {
return err
}
```
### Break work into steps
One instruction per action. Sequence them yourself so each step is independently debuggable and cacheable.
```typescript theme={null}
// Good - Explicit, ordered steps
await stagehand.act("type 'wireless headphones' into the search box");
await stagehand.act("press Enter in the search box");
await stagehand.act("click the 4 stars and up filter");
const { data } = await stagehand.extract(
"Extract the first three results with name and price",
z.object({
products: z.array(z.object({ name: z.string(), price: z.string() })),
}),
);
// Bad - One instruction carrying a whole workflow
await stagehand.act("search for wireless headphones under $100 and add the best rated one to cart");
```
```python theme={null}
# Good - Explicit, ordered steps
await stagehand.act("type 'wireless headphones' into the search box")
await stagehand.act("press Enter in the search box")
await stagehand.act("click the 4 stars and up filter")
class Product(BaseModel):
name: str
price: str
class Products(BaseModel):
products: list[Product]
result = (await stagehand.extract(
instruction="Extract the first three results with name and price",
schema=Products,
)).data
# Bad - One instruction carrying a whole workflow
await stagehand.act(
"search for wireless headphones under $100 and add the best rated one to cart"
)
```
```go theme={null}
// Good - Explicit, ordered steps
if _, err := client.Act(ctx, stagehand.ActInstruction("type 'wireless headphones' into the search box"), nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("press Enter in the search box"), nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("click the 4 stars and up filter"), nil); err != nil {
return err
}
type product struct {
Name string `json:"name"`
Price string `json:"price"`
}
type products struct {
Products []product `json:"products"`
}
extracted, err := stagehand.Extract[products](
ctx,
client,
"Extract the first three results with name and price",
nil,
)
if err != nil {
return err
}
for _, p := range extracted.Data.Products {
fmt.Println(p.Name, p.Price)
}
// Bad - One instruction carrying a whole workflow
if _, err := client.Act(ctx, stagehand.ActInstruction("search for wireless headphones under $100 and add the best rated one to cart"), nil); err != nil {
return err
}
```
### Include success criteria
Verify each step landed instead of assuming it did.
```typescript theme={null}
// Good - Verify the outcome
await stagehand.act("click the add to cart button");
const { data: cart } = await stagehand.extract(
"Extract the number of items shown in the cart badge",
z.object({ itemCount: z.number() }),
);
if (cart.itemCount !== 1) {
throw new Error(`Expected 1 item in cart, found ${cart.itemCount}`);
}
// Bad - No validation
await stagehand.act("add some items to cart");
```
```python theme={null}
# Good - Verify the outcome
await stagehand.act("click the add to cart button")
class Cart(BaseModel):
item_count: int
cart = (await stagehand.extract(
instruction="Extract the number of items shown in the cart badge",
schema=Cart,
)).data
if cart.item_count != 1:
raise RuntimeError(f"Expected 1 item in cart, found {cart.item_count}")
# Bad - No validation
await stagehand.act("add some items to cart")
```
```go theme={null}
// Good - Verify the outcome
if _, err := client.Act(ctx, stagehand.ActInstruction("click the add to cart button"), nil); err != nil {
return err
}
type cart struct {
ItemCount int `json:"item_count"`
}
result, err := stagehand.Extract[cart](
ctx,
client,
"Extract the number of items shown in the cart badge",
nil,
)
if err != nil {
return err
}
if result.Data.ItemCount != 1 {
return fmt.Errorf("expected 1 item in cart, found %d", result.Data.ItemCount)
}
// Bad - No validation
if _, err := client.Act(ctx, stagehand.ActInstruction("add some items to cart"), nil); err != nil {
return err
}
```
## Common mistakes to avoid
* **Combining multiple actions:** Keep each `act()` call to one action
* **Using vague descriptions:** Be specific about which elements to interact with
* **Exposing sensitive data:** Always use variables for credentials
* **Skipping validation:** Check results before proceeding
## Testing your prompts
1. **Start simple:** Test basic functionality first
2. **Add complexity gradually:** Build up to complex workflows
3. **Monitor results:** Use logging to understand what's happening
4. **Iterate based on failures:** Refine prompts when they don't work
Remember: Good prompting is iterative. When in doubt, be more specific rather than less.
# Speed optimization
Source: https://docs.stagehand.dev/v4/best-practices/speed-optimization
Optimize Stagehand performance for faster automation and reduced latency
Stagehand performance depends on several factors: DOM processing speed, LLM inference time, browser operations, and network latency. This guide provides proven strategies to maximize automation speed.
## Quick performance wins
### Plan ahead with observe
Use a single `observe()` call to plan multiple actions, then replay each returned `Action` through `act()`:
```typescript theme={null}
// Instead of sequential operations with multiple LLM calls
await stagehand.act("Fill name field"); // LLM call #1
await stagehand.act("Fill email field"); // LLM call #2
await stagehand.act("Select country dropdown"); // LLM call #3
// Use a single observe to plan all form fields: one LLM call
const { data: formFields } = await stagehand.observe("Find all form fields to fill");
// Replay each observed action: no further LLM inference
for (const field of formFields) {
await stagehand.act(field);
}
```
```python theme={null}
# Instead of sequential operations with multiple LLM calls
await stagehand.act("Fill name field") # LLM call #1
await stagehand.act("Fill email field") # LLM call #2
await stagehand.act("Select country dropdown") # LLM call #3
# Use a single observe to plan all form fields: one LLM call
form_fields = (await stagehand.observe(instruction="Find all form fields to fill")).data
# Replay each observed action: no further LLM inference
for field in form_fields:
await stagehand.act(field)
```
```go theme={null}
// Instead of sequential operations with multiple LLM calls
_, err := client.Act(ctx, stagehand.ActInstruction("Fill name field"), nil) // LLM call #1
_, err = client.Act(ctx, stagehand.ActInstruction("Fill email field"), nil) // LLM call #2
_, err = client.Act(ctx, stagehand.ActInstruction("Select country dropdown"), nil) // LLM call #3
// Use a single Observe to plan all form fields: one LLM call
instruction := "Find all form fields to fill"
observed, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
// Replay each observed action: no further LLM inference
for _, field := range observed.Data {
if _, err := client.Act(ctx, stagehand.ObservedAction(field), nil); err != nil {
return err
}
}
```
**Performance tip**: Passing an observed `Action` back to `act()` replays its recorded method and arguments without another inference call. A planned workflow therefore costs one `observe()` inference instead of one per step, which is the recommended pattern for multi-step workflows.
Learn advanced caching patterns and cache invalidation strategies
### Optimize DOM processing
Reduce DOM complexity before Stagehand processes the page. Scoping is usually the biggest win: pass a locator so Stagehand snapshots one container instead of the whole document, and prune known-noisy subtrees.
```typescript theme={null}
// Scope the snapshot to the region you care about
await stagehand.act("Click the submit button", {
locator: page.locator("#checkout"),
ignoreLocators: [
page.locator("nav"),
page.locator(".cookie-banner"),
page.locator("#sidebar-ads"),
],
});
// Or strip heavy elements before Stagehand reads the page
await page.evaluate(`
document.querySelectorAll('video, iframe').forEach(el => el.remove());
document.querySelectorAll('[style*="animation"]').forEach(el => {
el.style.animation = 'none';
});
`);
// Then continue with other Stagehand operations
```
```python theme={null}
# Scope the snapshot to the region you care about
await stagehand.act(
"Click the submit button",
locator=page.locator("#checkout"),
ignore_locators=[
page.locator("nav"),
page.locator(".cookie-banner"),
page.locator("#sidebar-ads"),
],
)
# Or strip heavy elements before Stagehand reads the page
await page.evaluate("""
document.querySelectorAll('video, iframe').forEach(el => el.remove());
document.querySelectorAll('[style*="animation"]').forEach(el => {
el.style.animation = 'none';
});
""")
# Then continue with other Stagehand operations
```
```go theme={null}
// Scope the snapshot to the region you care about
if _, err := client.Act(ctx, stagehand.ActInstruction("Click the submit button"), &stagehand.StagehandClientActOptions{
Page: page,
Locator: page.Locator("#checkout"),
IgnoreLocators: []*stagehand.PageLocator{
page.Locator("nav"),
page.Locator(".cookie-banner"),
page.Locator("#sidebar-ads"),
},
}); err != nil {
return err
}
// Or strip heavy elements before Stagehand reads the page
if _, err := page.Evaluate(ctx, `
document.querySelectorAll('video, iframe').forEach(el => el.remove());
document.querySelectorAll('[style*="animation"]').forEach(el => {
el.style.animation = 'none';
});
`); err != nil {
return err
}
// Then continue with other Stagehand operations
```
Removing iframes speeds up snapshots, but Stagehand traverses iframes by default. Only strip them when you know the content you need lives in the main frame.
### Set appropriate timeouts
Use shorter timeouts for simple operations and longer ones for complex page loads. Lowering the DOM settle timeout also shaves time off every `act()` call on stable pages.
```typescript theme={null}
// Stable pages: shorten the settle wait (default is 5000ms)
const browser = await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY });
const stagehand = await Stagehand.create({
browser,
domSettleTimeoutMs: 1000,
});
// Simple actions: reduce the action timeout
await stagehand.act("Click the login button", {
timeout: 5000,
});
// Complex page loads: optimize navigation
const page = await browser.context.activePage();
await page.goto("https://heavy-spa.com", {
waitUntil: "domcontentloaded", // Don't wait for all resources
timeout: 15000,
});
```
```python theme={null}
# Stable pages: shorten the settle wait (default is 5000ms)
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
dom_settle_timeout_ms=1000,
)
# Simple actions: reduce the action timeout
await stagehand.act("Click the login button", timeout=5000)
# Complex page loads: optimize navigation
page = await browser.context.active_page()
await page.goto(
"https://heavy-spa.com",
wait_until="domcontentloaded", # Don't wait for all resources
timeout=15000,
)
```
```go theme={null}
// Stable pages: shorten the settle wait (default is 5000ms)
apiKey := os.Getenv("BROWSERBASE_API_KEY")
domSettleTimeoutMs := 1000
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
DOMSettleTimeoutMs: &domSettleTimeoutMs,
})
if err != nil {
return err
}
// Simple actions: reduce the action timeout
timeout := 5000.0
if _, err := client.Act(ctx, stagehand.ActInstruction("Click the login button"), &stagehand.StagehandClientActOptions{
Timeout: &timeout,
}); err != nil {
return err
}
// Complex page loads: optimize navigation
browserContext, err := browser.Context()
if err != nil {
return err
}
page, err := browserContext.ActivePage(ctx)
if err != nil {
return err
}
if page == nil {
return errors.New("Stagehand has no active page")
}
waitUntil := stagehand.LoadStateDOMContentLoaded // Don't wait for all resources
navTimeout := 15000
if _, err := page.Goto(ctx, "https://heavy-spa.com", &stagehand.PageNavigationOptions{
WaitUntil: &waitUntil,
Timeout: &navTimeout,
}); err != nil {
return err
}
```
## Performance monitoring and benchmarking
Track performance metrics and measure optimization impact:
### Performance tracking
```typescript theme={null}
class PerformanceTracker {
private speedMetrics: Map = new Map();
async timedAct(stagehand: Stagehand, prompt: string) {
const start = Date.now();
const result = await stagehand.act(prompt);
const duration = Date.now() - start;
if (!this.speedMetrics.has(prompt)) {
this.speedMetrics.set(prompt, []);
}
this.speedMetrics.get(prompt)!.push(duration);
console.log(`Action "${prompt}" took ${duration}ms`);
return result;
}
getAverageTime(prompt: string): number {
const times = this.speedMetrics.get(prompt) || [];
if (times.length === 0) return 0;
return times.reduce((a, b) => a + b, 0) / times.length;
}
}
```
```python theme={null}
from time import perf_counter
from stagehand import Stagehand
class PerformanceTracker:
def __init__(self) -> None:
self.speed_metrics: dict[str, list[float]] = {}
async def timed_act(self, stagehand: Stagehand, prompt: str):
start = perf_counter()
result = await stagehand.act(prompt)
duration = (perf_counter() - start) * 1000
self.speed_metrics.setdefault(prompt, []).append(duration)
print(f'Action "{prompt}" took {duration:.0f}ms')
return result
def get_average_time(self, prompt: str) -> float:
times = self.speed_metrics.get(prompt, [])
return sum(times) / len(times) if times else 0.0
```
```go theme={null}
type performanceTracker struct {
mu sync.Mutex
speedMetrics map[string][]time.Duration
}
func (t *performanceTracker) timedAct(
ctx context.Context,
client *stagehand.Stagehand,
prompt string,
) (stagehand.ActResult, error) {
start := time.Now()
result, err := client.Act(ctx, stagehand.ActInstruction(prompt), nil)
duration := time.Since(start)
t.mu.Lock()
if t.speedMetrics == nil {
t.speedMetrics = map[string][]time.Duration{}
}
t.speedMetrics[prompt] = append(t.speedMetrics[prompt], duration)
t.mu.Unlock()
fmt.Printf("Action %q took %dms\n", prompt, duration.Milliseconds())
return result, err
}
func (t *performanceTracker) getAverageTime(prompt string) time.Duration {
t.mu.Lock()
defer t.mu.Unlock()
times := t.speedMetrics[prompt]
if len(times) == 0 {
return 0
}
var total time.Duration
for _, d := range times {
total += d
}
return total / time.Duration(len(times))
}
```
Example Output:
```
Action "Fill form" took 1000ms
Action "Click submit" took 2000ms
Action "Confirm submission" took 5000ms
```
### Before vs after benchmarking
```typescript theme={null}
// Before optimization
console.time("workflow");
await stagehand.act("Fill form");
await stagehand.act("Click submit");
await stagehand.act("Confirm submission");
console.timeEnd("workflow"); // 8000ms
// After optimization with observe planning
console.time("workflow-optimized");
const { data: workflowActions } = await stagehand.observe("Find form, submit, and confirm elements");
// Replay actions sequentially to avoid conflicts
for (const action of workflowActions) {
await stagehand.act(action);
}
console.timeEnd("workflow-optimized"); // 500ms
```
```python theme={null}
from time import perf_counter
# Before optimization
start = perf_counter()
await stagehand.act("Fill form")
await stagehand.act("Click submit")
await stagehand.act("Confirm submission")
print(f"workflow: {(perf_counter() - start) * 1000:.0f}ms") # 8000ms
# After optimization with observe planning
start = perf_counter()
workflow_actions = (await stagehand.observe(
instruction="Find form, submit, and confirm elements"
)).data
# Replay actions sequentially to avoid conflicts
for action in workflow_actions:
await stagehand.act(action)
print(f"workflow-optimized: {(perf_counter() - start) * 1000:.0f}ms") # 500ms
```
```go theme={null}
// Before optimization
start := time.Now()
if _, err := client.Act(ctx, stagehand.ActInstruction("Fill form"), nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("Click submit"), nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("Confirm submission"), nil); err != nil {
return err
}
fmt.Printf("workflow: %dms\n", time.Since(start).Milliseconds()) // 8000ms
// After optimization with Observe planning
start = time.Now()
instruction := "Find form, submit, and confirm elements"
observed, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
// Replay actions sequentially to avoid conflicts
for _, action := range observed.Data {
if _, err := client.Act(ctx, stagehand.ObservedAction(action), nil); err != nil {
return err
}
}
fmt.Printf("workflow-optimized: %dms\n", time.Since(start).Milliseconds()) // 500ms
```
Example Output:
```
workflow: 8000ms
workflow-optimized: 500ms
```
Server-side caching gets you the same result without restructuring your code: when the replay succeeds, a cached `act()` runs the recorded action with no inference at all. If the recorded selector no longer resolves, Stagehand falls back to the full inference pipeline and caches the new action. See the [caching guide](/v4/best-practices/caching).
Track token usage and inference timing per run
## Related resources
Advanced caching patterns for maximum performance
Balance speed improvements with cost considerations
Optimize Browserbase settings for speed
Choose the right model for speed vs accuracy
# Observe use cases
Source: https://docs.stagehand.dev/v4/best-practices/usecase-observe
Real-world patterns for planning automations with observe()
## Real-world use cases
### E-commerce product discovery
```typescript theme={null}
// Discover product interaction elements
const { data: productActions } = await stagehand.observe(
"Find add to cart buttons, size selectors, and product images",
);
// Categorize actions by type
const cartButtons = productActions.filter(a =>
a.description.toLowerCase().includes("cart")
);
const sizeOptions = productActions.filter(a =>
a.description.toLowerCase().includes("size")
);
// Execute purchase workflow
if (sizeOptions.length > 0) {
await page.locator(sizeOptions[0].selector).click(); // Select size first
}
if (cartButtons.length > 0) {
await page.locator(cartButtons[0].selector).click(); // Then add to cart
}
```
```python theme={null}
# Discover product interaction elements
product_actions = (await stagehand.observe(
instruction="Find add to cart buttons, size selectors, and product images",
)).data
# Categorize actions by type
cart_buttons = [a for a in product_actions if "cart" in a.description.lower()]
size_options = [a for a in product_actions if "size" in a.description.lower()]
# Execute purchase workflow
if size_options:
await page.locator(size_options[0].selector).click() # Select size first
if cart_buttons:
await page.locator(cart_buttons[0].selector).click() # Then add to cart
```
```go theme={null}
// Discover product interaction elements
instruction := "Find add to cart buttons, size selectors, and product images"
observed, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
// Categorize actions by type
var cartButtons, sizeOptions []stagehand.Action
for _, action := range observed.Data {
description := strings.ToLower(action.Description)
if strings.Contains(description, "cart") {
cartButtons = append(cartButtons, action)
}
if strings.Contains(description, "size") {
sizeOptions = append(sizeOptions, action)
}
}
// Execute purchase workflow
if len(sizeOptions) > 0 {
if err := page.Locator(sizeOptions[0].Selector).Click(ctx, nil); err != nil { // Select size first
return err
}
}
if len(cartButtons) > 0 {
if err := page.Locator(cartButtons[0].Selector).Click(ctx, nil); err != nil { // Then add to cart
return err
}
}
```
### Form handling & validation
`valueFor` (`value_for` in Python) is a helper you supply: it maps an observed field description to the value you want to type.
```typescript theme={null}
// Analyze form structure before filling
const { data: formElements } = await stagehand.observe(
"Find form fields, validation messages, and submit buttons",
);
// Check for required fields
const requiredFields = formElements.filter(e =>
e.description.includes("required") || e.description.includes("*")
);
console.log(`Found ${requiredFields.length} required fields to complete`);
// Fill form systematically
for (const field of requiredFields) {
await page.locator(field.selector).fill(valueFor(field.description));
}
```
```python theme={null}
# Analyze form structure before filling
form_elements = (await stagehand.observe(
instruction="Find form fields, validation messages, and submit buttons",
)).data
# Check for required fields
required_fields = [
e for e in form_elements if "required" in e.description or "*" in e.description
]
print(f"Found {len(required_fields)} required fields to complete")
# Fill form systematically
for field in required_fields:
await page.locator(field.selector).fill(value_for(field.description))
```
```go theme={null}
// Analyze form structure before filling
instruction := "Find form fields, validation messages, and submit buttons"
observed, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
// Check for required fields
var requiredFields []stagehand.Action
for _, element := range observed.Data {
if strings.Contains(element.Description, "required") || strings.Contains(element.Description, "*") {
requiredFields = append(requiredFields, element)
}
}
fmt.Printf("Found %d required fields to complete\n", len(requiredFields))
// Fill form systematically
for _, field := range requiredFields {
if err := page.Locator(field.Selector).Fill(ctx, valueFor(field.Description)); err != nil {
return err
}
}
```
### Dynamic content & SPA navigation
```typescript theme={null}
// Wait for and discover dynamically loaded content
await page.waitForLoadState("networkidle");
const { data: dynamicElements } = await stagehand.observe(
"Find newly loaded content, infinite scroll triggers, or loading indicators",
{ timeout: 15000 }, // Allow longer for dynamic content
);
// Handle infinite scroll
const scrollTriggers = dynamicElements.filter(e =>
e.description.toLowerCase().includes("load more") ||
e.description.toLowerCase().includes("scroll")
);
if (scrollTriggers.length > 0) {
await page.locator(scrollTriggers[0].selector).click();
// Recursively observe new content
const { data: newContent } = await stagehand.observe("Find additional items");
}
```
```python theme={null}
# Wait for and discover dynamically loaded content
await page.wait_for_load_state("networkidle")
dynamic_elements = (await stagehand.observe(
instruction="Find newly loaded content, infinite scroll triggers, or loading indicators",
timeout=15000, # Allow longer for dynamic content
)).data
# Handle infinite scroll
scroll_triggers = [
e
for e in dynamic_elements
if "load more" in e.description.lower() or "scroll" in e.description.lower()
]
if scroll_triggers:
await page.locator(scroll_triggers[0].selector).click()
# Recursively observe new content
new_content = (await stagehand.observe(instruction="Find additional items")).data
```
```go theme={null}
// Wait for and discover dynamically loaded content
if err := page.WaitForLoadState(ctx, stagehand.LoadStateNetworkIdle, nil); err != nil {
return err
}
timeout := 15000.0 // Allow longer for dynamic content
instruction := "Find newly loaded content, infinite scroll triggers, or loading indicators"
observed, err := client.Observe(ctx, &instruction, &stagehand.StagehandClientObserveOptions{
ObserveOptions: stagehand.ObserveOptions{Timeout: &timeout},
})
if err != nil {
return err
}
// Handle infinite scroll
var scrollTriggers []stagehand.Action
for _, element := range observed.Data {
description := strings.ToLower(element.Description)
if strings.Contains(description, "load more") || strings.Contains(description, "scroll") {
scrollTriggers = append(scrollTriggers, element)
}
}
if len(scrollTriggers) > 0 {
if err := page.Locator(scrollTriggers[0].Selector).Click(ctx, nil); err != nil {
return err
}
// Recursively observe new content
more := "Find additional items"
if _, err := client.Observe(ctx, &more, nil); err != nil {
return err
}
}
```
### Multi-step workflow planning
```typescript theme={null}
// Plan entire checkout flow upfront
async function planCheckoutWorkflow() {
// Step 1: Cart page analysis
await page.goto("/cart");
const { data: cartActions } = await stagehand.observe("Find checkout and cart modification options");
// Step 2: Checkout page analysis. Descriptions are model-generated, so
// lowercase them before matching.
const checkoutButton = cartActions.find(a =>
a.description.toLowerCase().includes("checkout")
);
if (checkoutButton) await page.locator(checkoutButton.selector).click();
const { data: checkoutActions } = await stagehand.observe("Find payment forms and shipping options");
// Step 3: Plan execution order
const shippingFields = checkoutActions.filter(a =>
a.description.toLowerCase().includes("shipping")
);
const paymentFields = checkoutActions.filter(a =>
a.description.toLowerCase().includes("payment")
);
const submitButton = checkoutActions.find(a =>
a.description.toLowerCase().includes("complete order")
);
return { shippingFields, paymentFields, submitButton };
}
// Execute planned workflow
const workflow = await planCheckoutWorkflow();
// Fill shipping, then payment, then submit
```
```python theme={null}
# Plan entire checkout flow upfront
async def plan_checkout_workflow():
# Step 1: Cart page analysis
await page.goto("/cart")
cart_actions = (await stagehand.observe(
instruction="Find checkout and cart modification options"
)).data
# Step 2: Checkout page analysis. Descriptions are model-generated, so
# lowercase them before matching.
checkout_button = next(
(a for a in cart_actions if "checkout" in a.description.lower()), None
)
if checkout_button is not None:
await page.locator(checkout_button.selector).click()
checkout_actions = (await stagehand.observe(
instruction="Find payment forms and shipping options"
)).data
# Step 3: Plan execution order
shipping_fields = [
a for a in checkout_actions if "shipping" in a.description.lower()
]
payment_fields = [a for a in checkout_actions if "payment" in a.description.lower()]
submit_button = next(
(a for a in checkout_actions if "complete order" in a.description.lower()), None
)
return shipping_fields, payment_fields, submit_button
# Execute planned workflow
workflow = await plan_checkout_workflow()
# Fill shipping, then payment, then submit
```
```go theme={null}
type checkoutPlan struct {
ShippingFields []stagehand.Action
PaymentFields []stagehand.Action
SubmitButton *stagehand.Action
}
// Plan entire checkout flow upfront
func planCheckoutWorkflow(
ctx context.Context,
client *stagehand.Stagehand,
page *stagehand.Page,
) (checkoutPlan, error) {
var plan checkoutPlan
// Step 1: Cart page analysis
if _, err := page.Goto(ctx, "/cart", nil); err != nil {
return plan, err
}
cartInstruction := "Find checkout and cart modification options"
cartActions, err := client.Observe(ctx, &cartInstruction, nil)
if err != nil {
return plan, err
}
// Step 2: Checkout page analysis. Descriptions are model-generated, so
// lowercase them before matching.
for _, action := range cartActions.Data {
if strings.Contains(strings.ToLower(action.Description), "checkout") {
if err := page.Locator(action.Selector).Click(ctx, nil); err != nil {
return plan, err
}
break
}
}
checkoutInstruction := "Find payment forms and shipping options"
checkoutActions, err := client.Observe(ctx, &checkoutInstruction, nil)
if err != nil {
return plan, err
}
// Step 3: Plan execution order
for i, action := range checkoutActions.Data {
description := strings.ToLower(action.Description)
switch {
case strings.Contains(description, "shipping"):
plan.ShippingFields = append(plan.ShippingFields, action)
case strings.Contains(description, "payment"):
plan.PaymentFields = append(plan.PaymentFields, action)
case strings.Contains(description, "complete order"):
plan.SubmitButton = &checkoutActions.Data[i]
}
}
return plan, nil
}
// Execute planned workflow
plan, err := planCheckoutWorkflow(ctx, client, page)
// Fill shipping, then payment, then submit
```
# User data
Source: https://docs.stagehand.dev/v4/best-practices/user-data
Persist browser data between sessions
## Local sessions
For local sessions, point `localBrowser.launch()` at a user data directory. Stagehand creates the directory if it does not exist and leaves it in place after the browser shuts down, so the next run starts from the same cookies and local storage:
```typescript theme={null}
import { localBrowser, Stagehand } from "@browserbasehq/stagehand";
const stagehand = await Stagehand.create({
browser: await localBrowser.launch({
userDataDir: "./browser-data",
}),
});
```
```python theme={null}
from stagehand import Stagehand, local_browser
browser = await local_browser.launch(user_data_dir="./browser-data")
stagehand = await Stagehand.create(browser=browser)
```
```go theme={null}
browser, err := stagehand.LaunchLocalBrowser(ctx, &stagehand.LocalBrowserLaunchOptions{
UserDataDir: "./browser-data",
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
When no user data directory is set, Stagehand launches Chrome against a temporary profile and deletes it on shutdown, so cookies and local storage do not carry over between runs. A directory you pass yourself is kept either way, so the setup above needs nothing extra.
The preserve flag governs only that generated temporary profile, never a directory you passed yourself:
`preserveUserDataDir` keeps the generated temporary directory after shutdown when you turn it on.
`preserve_user_data_dir` keeps the generated temporary directory after shutdown when you turn it on.
`PreserveUserDataDir` keeps the generated temporary directory after shutdown when you turn it on.
## Browserbase sessions
For Browserbase sessions, use [contexts](https://docs.browserbase.com/features/contexts) to persist browser data:
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
const stagehand = await Stagehand.create({
browser: await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
browserSettings: {
context: {
id: "my-context-id",
persist: true,
},
},
}),
});
```
```python theme={null}
import os
from stagehand import BrowserbaseBrowserSettings, Stagehand, browserbase
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
browser_settings=BrowserbaseBrowserSettings.model_validate({
"context": {
"id": "my-context-id",
"persist": True,
}
}),
)
stagehand = await Stagehand.create(browser=browser)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
persist := true
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
BrowserSettings: &stagehand.BrowserbaseBrowserSettings{
Context: &stagehand.BrowserbaseContext{
ID: "my-context-id",
Persist: &persist,
},
},
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
A persisted profile carries cookies, tokens, and site data across runs. Treat the directory or context ID as a credential: keep it out of version control and scope it to a single automation.
# Using multiple tabs
Source: https://docs.stagehand.dev/v4/best-practices/using-multiple-tabs
Act on multiple tabs with Stagehand
Web applications often open a new tab when someone clicks a button or link, and a script that only watches the original tab breaks when the content lands elsewhere. Stagehand follows the new tab for you.
## The Stagehand page
The active page is whichever tab Chrome has focused in its last-focused window, so a tab the browser opens and activates becomes the target on its own. `act()`, `observe()`, and `extract()` all default to that page.
The usual patterns keep working:
```typescript theme={null}
const page = await browser.context.activePage();
await page.goto("https://example.com");
await stagehand.act("click the button that opens a new tab");
// Stagehand now operates on the new tab automatically
const { data } = await stagehand.extract(
"get data from new tab",
z.object({ value: z.string() }),
);
console.log(data.value);
```
```python theme={null}
page = await browser.context.active_page()
await page.goto("https://example.com")
await stagehand.act("click the button that opens a new tab")
class Data(BaseModel):
value: str
# Stagehand now operates on the new tab automatically
result = await stagehand.extract(
instruction="get data from new tab",
schema=Data,
)
print(result.data.value)
```
```go theme={null}
page, err := browserContext.ActivePage(ctx)
if err != nil {
return err
}
if page == nil {
return errors.New("Stagehand has no active page")
}
if _, err := page.Goto(ctx, "https://example.com", nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("click the button that opens a new tab"), nil); err != nil {
return err
}
type data struct {
Value string `json:"value"`
}
// Stagehand now operates on the new tab automatically
extracted, err := stagehand.Extract[data](ctx, client, "get data from new tab", nil)
if err != nil {
return err
}
fmt.Println(extracted.Data.Value)
```
**Important**: The page object you captured before the click still refers to the *original* tab. Re-read the active page, or hold an explicit reference to each tab, when you need to reach back to it.
## Manual page management
For more control or multitab workflows, you can manage multiple tabs explicitly. Set the active page when you want the default target to follow you:
```typescript theme={null}
// Create a second page
await browser.context.newPage();
const pages = await browser.context.pages();
const githubPage = pages[0];
const pythonPage = pages[1];
// Navigate each page to different repositories
await githubPage.goto("https://github.com/browserbase/stagehand");
await pythonPage.goto("https://github.com/browserbase/stagehand-python");
// Extract data from both pages simultaneously
const starsSchema = z.object({ stars: z.number() });
const [stagehandStars, stagehandPythonStars] = await Promise.all([
stagehand.extract("extract the repository stars", starsSchema, { page: githubPage }),
stagehand.extract("extract the repository stars", starsSchema, { page: pythonPage }),
]);
console.log(`Stagehand stars: ${stagehandStars.data.stars}`);
console.log(`Stagehand-Python stars: ${stagehandPythonStars.data.stars}`);
// Make one of them the default target for later calls
await browser.context.setActivePage(githubPage);
```
```python theme={null}
import asyncio
from pydantic import BaseModel
class Stars(BaseModel):
stars: int
# Create a second page
await browser.context.new_page()
pages = await browser.context.pages()
github_page = pages[0]
python_page = pages[1]
# Navigate each page to different repositories
await github_page.goto("https://github.com/browserbase/stagehand")
await python_page.goto("https://github.com/browserbase/stagehand-python")
# Extract data from both pages simultaneously
stagehand_stars, stagehand_python_stars = await asyncio.gather(
stagehand.extract(
instruction="extract the repository stars", schema=Stars, page=github_page
),
stagehand.extract(
instruction="extract the repository stars", schema=Stars, page=python_page
),
)
print(f"Stagehand stars: {stagehand_stars.data.stars}")
print(f"Stagehand-Python stars: {stagehand_python_stars.data.stars}")
# Make one of them the default target for later calls
await browser.context.set_active_page(github_page)
```
```go theme={null}
type stars struct {
Stars int `json:"stars"`
}
// Create a second page
if _, err := browserContext.NewPage(ctx); err != nil {
return err
}
pages, err := browserContext.Pages(ctx)
if err != nil {
return err
}
githubPage, pythonPage := pages[0], pages[1]
// Navigate each page to different repositories
if _, err := githubPage.Goto(ctx, "https://github.com/browserbase/stagehand", nil); err != nil {
return err
}
if _, err := pythonPage.Goto(ctx, "https://github.com/browserbase/stagehand-python", nil); err != nil {
return err
}
// Extract data from both pages concurrently
group, groupCtx := errgroup.WithContext(ctx)
var stagehandStars, stagehandPythonStars stars
group.Go(func() error {
result, err := stagehand.Extract[stars](groupCtx, client, "extract the repository stars",
&stagehand.StagehandClientExtractOptions{Page: githubPage})
stagehandStars = result.Data
return err
})
group.Go(func() error {
result, err := stagehand.Extract[stars](groupCtx, client, "extract the repository stars",
&stagehand.StagehandClientExtractOptions{Page: pythonPage})
stagehandPythonStars = result.Data
return err
})
if err := group.Wait(); err != nil {
return err
}
fmt.Printf("Stagehand stars: %d\n", stagehandStars.Stars)
fmt.Printf("Stagehand-Python stars: %d\n", stagehandPythonStars.Stars)
// Make one of them the default target for later calls
if err := browserContext.SetActivePage(ctx, githubPage); err != nil {
return err
}
```
Locators belong to the page they were created on. `page.locator(selector)` carries that page's identity, so a locator from one tab never resolves against another.
## Next steps
Target a specific tab with the `page` option on any primitive.
Stagehand traverses iframes automatically. Scope with a selector when you need to narrow the snapshot.
Manage browser contexts and sessions for complex automation scenarios.
Set log levels and read structured records while you debug.
# Browser
Source: https://docs.stagehand.dev/v4/configuration/browser
Configure Stagehand on Browserbase or locally
You get a browser from one of Stagehand's browser factories and hand it to `Stagehand.create()`. There are three ways to get one:
* **Browserbase (`browserbase.launch`):** Cloud-managed browser infrastructure optimized for production web automation at scale
* **Local (`localBrowser.launch`):** Run browsers directly on your machine for development and debugging
* **Attach over CDP (`localBrowser.connect`):** Attach to any Chromium browser you are already running, by URL
Stagehand closes only the browsers it launched, so `browser.close()` is yours to call.
```typescript theme={null}
const cloud = await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY });
const local = await localBrowser.launch({ headless: true });
const connected = await localBrowser.connect({ cdpUrl: "http://127.0.0.1:9222" });
await Stagehand.create({ browser: cloud });
```
```python theme={null}
cloud = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
local = await local_browser.launch(headless=True)
connected = await local_browser.connect(cdp_url="http://127.0.0.1:9222")
await Stagehand.create(browser=cloud)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
cloud, _ := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{APIKey: apiKey})
stagehand.Create(ctx, stagehand.CreateOptions{Browser: cloud})
// Or start a browser on your machine, or attach to one you already run:
stagehand.LaunchLocalBrowser(ctx, &stagehand.LocalBrowserLaunchOptions{Headless: true})
stagehand.ConnectLocalBrowser(ctx, stagehand.LocalBrowserConnectOptions{
CDPURL: "http://127.0.0.1:9222",
})
```
## Browserbase environment
Browserbase provides managed cloud browser infrastructure optimized for web automation at scale. It offers advanced features like stealth mode, proxy support, and persistent contexts. It is also the only browser that supports [server-side caching](/v4/best-practices/caching) and the [Model Gateway](/v4/configuration/models#model-gateway).
Read the Browserbase documentation for the full set of session settings Stagehand passes through.
### Multi-region support
Browserbase runs browsers in four regions, so you can cut latency by starting the browser near your users or your target site, and keep session data in a required jurisdiction. Set the region on `browserbase.launch()`; `us-west-2` is the default.
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
region: "eu-central-1", // Browser runs in Frankfurt
});
const stagehand = await Stagehand.create({ browser });
```
```python theme={null}
import os
from stagehand import Stagehand, browserbase
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
region="eu-central-1", # Browser runs in Frankfurt
)
stagehand = await Stagehand.create(browser=browser)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
region := stagehand.BrowserbaseRegionEUCentral1 // Browser runs in Frankfurt
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
Region: ®ion,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
Supported regions: `us-west-2` (default), `us-east-1`, `eu-central-1`, `ap-southeast-1`.
You normally do not need to configure a regional endpoint. Stagehand picks the API deployment that matches your session's region on its own, including for [server-side caching](/v4/best-practices/caching). An explicit Stagehand `apiUrl` overrides that regional selection.
### API endpoint overrides
Browserbase session management and Stagehand managed services use separate APIs. Both default to the production service. The SDKs do not read either URL from an environment variable. Pass `baseUrl` to Browserbase and `apiUrl` to Stagehand when testing another deployment. Use the service origin without `/v1`.
```typescript theme={null}
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
baseUrl: "https://api.dev.browserbase.com",
});
const stagehand = await Stagehand.create({
browser,
apiUrl: "https://api.stagehand.dev.browserbase.com",
});
```
```python theme={null}
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
base_url="https://api.dev.browserbase.com",
)
stagehand = await Stagehand.create(
browser=browser,
api_url="https://api.stagehand.dev.browserbase.com",
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
stagehandAPIURL := "https://api.stagehand.dev.browserbase.com"
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
BaseURL: "https://api.dev.browserbase.com",
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
APIURL: &stagehandAPIURL,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
The Browserbase URL controls extension, session, and connection requests that `browserbase.launch()` and `browserbase.connect()` make. The Stagehand URL controls Model Gateway and managed-cache requests that the browser runtime makes.
### Environment variables
Before getting started, set up the required environment variables:
```bash theme={null}
export BROWSERBASE_API_KEY=your_api_key_here
```
Get your API key from the [Browserbase Dashboard](https://browserbase.com/overview). Stagehand does not read this variable for you: read it in your own code and pass it to `browserbase.launch()`.
### Using Stagehand with Browserbase
#### Basic setup
The simplest way to get started is with default settings. `browserbase.launch()` needs nothing but your Browserbase API key:
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
});
const stagehand = await Stagehand.create({ browser });
```
```python theme={null}
import os
from stagehand import Stagehand, browserbase
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
)
stagehand = await Stagehand.create(browser=browser)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{APIKey: apiKey})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
#### Advanced configuration
Configure browser settings, proxy support, and other session parameters directly on `browserbase.launch()`:
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
proxies: true,
region: "us-west-2",
browserSettings: {
viewport: { width: 1920, height: 1080 },
blockAds: true,
},
});
const stagehand = await Stagehand.create({ browser });
```
```python theme={null}
import os
from stagehand import BrowserbaseBrowserSettings, Stagehand, browserbase
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
proxies=True,
region="us-west-2",
browser_settings=BrowserbaseBrowserSettings.model_validate({
"viewport": {"width": 1920, "height": 1080},
"block_ads": True,
}),
)
stagehand = await Stagehand.create(browser=browser)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
region := stagehand.BrowserbaseRegionUSWest2
proxies := stagehand.BrowserbaseProxyEnabled(true)
blockAds := true
width, height := 1920.0, 1080.0
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
Proxies: &proxies,
Region: ®ion,
BrowserSettings: &stagehand.BrowserbaseBrowserSettings{
Viewport: &stagehand.BrowserbaseViewport{Width: &width, Height: &height},
BlockAds: &blockAds,
},
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
```typescript theme={null}
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
proxies: true,
region: "us-west-2",
timeout: 3600, // 1 hour session timeout
keepAlive: true, // Available on Startup plan
browserSettings: {
verified: false, // this is a Scale Plan feature - reach out to support@browserbase.com to enable
blockAds: true,
solveCaptchas: true,
recordSession: false,
viewport: {
width: 1920,
height: 1080,
},
},
userMetadata: {
userId: "automation-user-123",
environment: "production",
},
});
```
```python theme={null}
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
proxies=True,
region="us-west-2",
timeout=3600, # 1 hour session timeout
keep_alive=True, # Available on Startup plan
browser_settings=BrowserbaseBrowserSettings.model_validate({
# verified is a Scale Plan feature: contact support@browserbase.com to enable
"verified": False,
"block_ads": True,
"solve_captchas": True,
"record_session": False,
"viewport": {"width": 1920, "height": 1080},
}),
user_metadata={
"user_id": "automation-user-123",
"environment": "production",
},
)
stagehand = await Stagehand.create(browser=browser)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
region := stagehand.BrowserbaseRegionUSWest2
proxies := stagehand.BrowserbaseProxyEnabled(true)
timeout := 3600.0 // 1 hour session timeout
keepAlive := true // Available on Startup plan
// verified is a Scale Plan feature: contact support@browserbase.com to enable
verified := false
blockAds := true
solveCaptchas := true
recordSession := false
width, height := 1920.0, 1080.0
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
Proxies: &proxies,
Region: ®ion,
Timeout: &timeout,
KeepAlive: &keepAlive,
BrowserSettings: &stagehand.BrowserbaseBrowserSettings{
Verified: &verified,
BlockAds: &blockAds,
SolveCaptchas: &solveCaptchas,
RecordSession: &recordSession,
Viewport: &stagehand.BrowserbaseViewport{Width: &width, Height: &height},
},
UserMetadata: map[string]json.RawMessage{
"userId": json.RawMessage(`"automation-user-123"`),
"environment": json.RawMessage(`"production"`),
},
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
When `browserbase.launch()` creates a session without an `extensionId`, Stagehand uploads its extension and starts the session against it. Stagehand deletes the upload when you call `browser.close()` for a launched session without `keepAlive` enabled.
### Alternative: Browserbase SDK
If you prefer to manage sessions directly, upload the Stagehand extension to Browserbase first and include that uploaded extension when you create the session. Extensions cannot be added after a browser session starts. You can then hand Stagehand the resulting session through `browserbase.connect()`:
The extension ID used to create the session is the uploaded-extension resource ID returned by the Browserbase Extensions API. `browserbase.launch()` handles this upload and session configuration automatically.
```typescript theme={null}
import { Browserbase } from "@browserbasehq/sdk";
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
const bb = new Browserbase({
apiKey: process.env.BROWSERBASE_API_KEY!,
});
const session = await bb.sessions.create({
projectId: process.env.BROWSERBASE_PROJECT_ID!,
extensionId: process.env.BROWSERBASE_STAGEHAND_EXTENSION_ID!,
// Add configuration options here
});
// Attach by session ID so the browser keeps its Browserbase identity
const browser = await browserbase.connect({
apiKey: process.env.BROWSERBASE_API_KEY!,
sessionId: session.id,
});
const stagehand = await Stagehand.create({ browser });
```
```python theme={null}
import os
from browserbase import Browserbase
from stagehand import Stagehand
from stagehand import browserbase as stagehand_browserbase
bb = Browserbase(api_key=os.environ["BROWSERBASE_API_KEY"])
session = bb.sessions.create(
project_id=os.environ["BROWSERBASE_PROJECT_ID"],
extension_id=os.environ["BROWSERBASE_STAGEHAND_EXTENSION_ID"],
# Add configuration options here
)
# Attach by session ID so the browser keeps its Browserbase identity
browser = await stagehand_browserbase.connect(
api_key=os.environ["BROWSERBASE_API_KEY"],
session_id=session.id,
)
stagehand = await Stagehand.create(browser=browser)
```
```go theme={null}
// Create the session with your Browserbase client of choice, including the
// uploaded Stagehand extension, then attach by session ID.
extensionID := os.Getenv("BROWSERBASE_STAGEHAND_EXTENSION_ID")
session, err := createBrowserbaseSession(ctx, extensionID)
if err != nil {
return err
}
browser, err := stagehand.ConnectBrowserbase(ctx, stagehand.BrowserbaseConnectOptions{
APIKey: os.Getenv("BROWSERBASE_API_KEY"),
SessionID: session.ID,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
#### Connecting over CDP
`localBrowser.connect()` attaches to a Chromium browser that is already running and exposing a DevTools endpoint. By default, it loads Stagehand's extension from the SDK host, so use it when the SDK and browser share a filesystem. For an existing Browserbase session, use `browserbase.connect()` with its session ID as shown above.
```typescript theme={null}
import { localBrowser, Stagehand } from "@browserbasehq/stagehand";
const browser = await localBrowser.connect({
cdpUrl: "http://127.0.0.1:9222",
});
const stagehand = await Stagehand.create({ browser });
```
```python theme={null}
from stagehand import Stagehand, local_browser
browser = await local_browser.connect(
cdp_url="http://127.0.0.1:9222",
)
stagehand = await Stagehand.create(browser=browser)
```
```go theme={null}
browser, err := stagehand.ConnectLocalBrowser(ctx, stagehand.LocalBrowserConnectOptions{
CDPURL: "http://127.0.0.1:9222",
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, browser.Close(ctx)) }()
client, err := stagehand.Create(ctx, stagehand.CreateOptions{Browser: browser})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
Stagehand never closes a browser it did not launch. When you connect to an existing browser, `close()` tears down the Stagehand connection and leaves the browser running.
## Local environment
Run browsers directly on your machine when you want full control over the browser process and its launch flags. This suits development, debugging, and custom browser setups.
### Environment comparison
| Feature | Browserbase | Local |
| - | - | - |
| **Scalability** | High (cloud-managed) | Limited (local resources) |
| **Stealth features** | Advanced fingerprinting | Basic stealth |
| **Proxy support** | Built-in residential proxies | Manual configuration |
| **Session persistence** | Cloud context storage | File-based user data |
| **Geographic distribution** | Multi-region deployment | Single machine |
| **Debugging** | Session recordings and logs | Direct DevTools access |
| **Setup complexity** | API key only | Browser installation required |
| **Server-side caching** | Supported | Not available |
| **Model Gateway** | Supported | Not available |
| **Cost** | Usage-based pricing | Infrastructure and maintenance |
| **Best for** | Production, scale, compliance | Development, debugging |
### Basic local setup
```typescript theme={null}
import { localBrowser, Stagehand } from "@browserbasehq/stagehand";
const browser = await localBrowser.launch();
const stagehand = await Stagehand.create({ browser });
```
```python theme={null}
from stagehand import Stagehand, local_browser
browser = await local_browser.launch()
stagehand = await Stagehand.create(browser=browser)
```
```go theme={null}
browser, err := stagehand.LaunchLocalBrowser(ctx, &stagehand.LocalBrowserLaunchOptions{})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
### Advanced local configuration
Customize browser launch options for local development:
```typescript theme={null}
import { localBrowser, Stagehand } from "@browserbasehq/stagehand";
const browser = await localBrowser.launch({
headless: false, // Show browser window
devtools: true, // Open developer tools
viewport: { width: 1280, height: 720 },
executablePath: "/opt/google/chrome/chrome", // Custom Chrome path
port: 9222, // Fixed CDP debugging port
ignoreDefaultArgs: [
"--disable-sync",
], // Remove specific default launch args
args: [
"--disable-web-security",
"--allow-running-insecure-content",
],
userDataDir: "./chrome-user-data", // Persist browser data
preserveUserDataDir: true, // Keep data after closing
chromiumSandbox: false, // Disable sandbox (adds --no-sandbox)
ignoreHTTPSErrors: true, // Ignore certificate errors
locale: "en-US", // Set browser language
deviceScaleFactor: 1.0, // Display scaling
hasTouch: false, // Emulate touch input
proxy: {
server: "http://proxy.example.com:8080",
username: "user",
password: "pass",
},
downloadsPath: "./downloads", // Download directory
acceptDownloads: true, // Allow downloads
});
const stagehand = await Stagehand.create({ browser });
```
```python theme={null}
from stagehand import Stagehand, local_browser
browser = await local_browser.launch(
headless=False, # Show browser window
devtools=True, # Open developer tools
viewport_width=1280,
viewport_height=720,
executable_path="/opt/google/chrome/chrome", # Custom Chrome path
port=9222, # Fixed CDP debugging port
ignore_default_args=["--disable-sync"], # Remove specific default launch args
args=[
"--disable-web-security",
"--allow-running-insecure-content",
],
user_data_dir="./chrome-user-data", # Persist browser data
preserve_user_data_dir=True, # Keep data after closing
chromium_sandbox=False, # Disable sandbox (adds --no-sandbox)
ignore_https_errors=True, # Ignore certificate errors
locale="en-US", # Set browser language
device_scale_factor=1.0, # Display scaling
has_touch=False, # Emulate touch input
proxy_server="http://proxy.example.com:8080",
proxy_username="user",
proxy_password="pass",
downloads_path="./downloads", # Download directory
accept_downloads=True, # Allow downloads
)
stagehand = await Stagehand.create(browser=browser)
```
```go theme={null}
chromiumSandbox := false // Disable sandbox (adds --no-sandbox)
acceptDownloads := true // Allow downloads
deviceScaleFactor := 1.0 // Display scaling
browser, err := stagehand.LaunchLocalBrowser(ctx, &stagehand.LocalBrowserLaunchOptions{
Headless: false, // Show browser window
Devtools: true, // Open developer tools
Viewport: &stagehand.LocalViewport{Width: 1280, Height: 720},
ExecutablePath: "/opt/google/chrome/chrome", // Custom Chrome path
Port: 9222, // Fixed CDP debugging port
IgnoreDefaultArgs: &stagehand.IgnoreDefaultArgs{ // Remove specific default launch args
Args: []string{"--disable-sync"},
},
Args: []string{
"--disable-web-security",
"--allow-running-insecure-content",
},
UserDataDir: "./chrome-user-data", // Persist browser data
PreserveUserDataDir: true, // Keep data after closing
ChromiumSandbox: &chromiumSandbox,
IgnoreHTTPSErrors: true, // Ignore certificate errors
Locale: "en-US", // Set browser language
DeviceScaleFactor: &deviceScaleFactor,
HasTouch: false, // Emulate touch input
Proxy: &stagehand.LocalProxyConfig{
Server: "http://proxy.example.com:8080",
Username: "user",
Password: "pass",
},
DownloadsPath: "./downloads", // Download directory
AcceptDownloads: &acceptDownloads,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
Authenticated local proxies are not supported yet. Setting a proxy `username` or `password` raises in all three SDKs; a proxy `server` and `bypass` list work everywhere.
#### Default local launch arguments
When Stagehand launches a local browser, it adds the following Chrome arguments before any values you pass in the launch `args`:
```typescript theme={null}
[
"--enable-unsafe-extension-debugging",
"--remote-allow-origins=*",
"--window-size=1280,800",
"--enable-features=WebMCPTesting,DevToolsWebMCPSupport",
]
```
```python theme={null}
[
"--enable-unsafe-extension-debugging",
"--remote-allow-origins=*",
"--window-size=1280,800",
"--enable-features=WebMCPTesting,DevToolsWebMCPSupport",
]
```
```go theme={null}
[]string{
"--enable-unsafe-extension-debugging",
"--remote-allow-origins=*",
"--window-size=1280,800",
"--enable-features=WebMCPTesting,DevToolsWebMCPSupport",
}
```
`--enable-unsafe-extension-debugging` is required so the SDK can attach a CDP session to the service worker hosting Stagehand's runtime. Chrome blocks debugger access to extension targets without it. `--enable-features=WebMCPTesting,DevToolsWebMCPSupport` turns on the browser support behind [WebMCP](/v4/basics/webmcp). `--window-size` follows the `viewport` option and falls back to `1280,800` when you do not set one.
A larger set of standard Chrome automation flags (disabling background networking, component updates, sync, translation, and similar) is prepended as well. To remove some of them, list the exact flags in the ignore-default-args option:
```typescript theme={null}
const browser = await localBrowser.launch({
ignoreDefaultArgs: ["--disable-sync"],
args: [
"--disable-features=site-per-process,IsolateOrigins",
"--renderer-process-limit=6",
],
});
```
```python theme={null}
browser = await local_browser.launch(
ignore_default_args=["--disable-sync"],
args=[
"--disable-features=site-per-process,IsolateOrigins",
"--renderer-process-limit=6",
],
)
stagehand = await Stagehand.create(browser=browser)
```
```go theme={null}
browser, err := stagehand.LaunchLocalBrowser(ctx, &stagehand.LocalBrowserLaunchOptions{
IgnoreDefaultArgs: &stagehand.IgnoreDefaultArgs{
Args: []string{"--disable-sync"},
},
Args: []string{
"--disable-features=site-per-process,IsolateOrigins",
"--renderer-process-limit=6",
},
})
if err != nil {
return err
}
stagehand.Create(ctx, stagehand.CreateOptions{Browser: browser})
```
Set the ignore-default-args option to `true` to remove all default arguments. Use a list to remove only exact matches while preserving the rest.
## Advanced configuration
### Keep alive
The keep-alive option controls whether the browser remains running after `close()` is called.
By default, Stagehand terminates the browser it launched and cleans up all resources when it shuts down. Turning keep-alive on keeps the browser running independently so you can reconnect to it later.
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
keepAlive: true,
});
const stagehand = await Stagehand.create({ browser });
const sessionId = browser.sessionId!;
// The browser session continues running after close()
await stagehand.close();
await browser.close();
// Later, reconnect to the same Browserbase session
const browser2 = await browserbase.connect({
apiKey: process.env.BROWSERBASE_API_KEY,
sessionId,
});
const stagehand2 = await Stagehand.create({ browser: browser2 });
```
```python theme={null}
import os
from stagehand import Stagehand, browserbase
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
keep_alive=True,
)
stagehand = await Stagehand.create(browser=browser)
session_id = browser.session_id
assert session_id is not None
# The browser session continues running after close()
await stagehand.close()
await browser.close()
# Later, reconnect to the same Browserbase session
browser2 = await browserbase.connect(
api_key=os.environ["BROWSERBASE_API_KEY"],
session_id=session_id,
)
stagehand2 = await Stagehand.create(browser=browser2)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
keepAlive := true
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
KeepAlive: &keepAlive,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{Browser: browser})
if err != nil {
return err
}
sessionID := browser.SessionID()
// The browser session continues running after Close()
if err := client.Close(ctx); err != nil {
return err
}
if err := browser.Close(ctx); err != nil {
return err
}
// Later, reconnect to the same Browserbase session
browser2, err := stagehand.ConnectBrowserbase(ctx, stagehand.BrowserbaseConnectOptions{
APIKey: apiKey,
SessionID: sessionID,
})
if err != nil {
return err
}
client2, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser2,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client2.Close(ctx)) }()
```
#### Behavior by environment
| Behavior | keep-alive on | keep-alive off (default) |
| - | - | - |
| **Browserbase** | Session stays active after `close()` | Session is terminated via API |
| **Local** | Chrome process continues running | Chrome process is killed and temp profile is removed |
| **Attached over CDP** | Always left running | Always left running (Stagehand never owns the browser) |
#### Local environment
When running locally with keep-alive on, Stagehand leaves the Chrome process running when your script exits. This is useful for debugging or for handing off a browser session to another process. Combine it with a fixed `port` so you can reattach with `localBrowser.connect()`.
```typescript theme={null}
const browser = await localBrowser.launch({
keepAlive: true,
headless: false,
port: 9222,
});
const stagehand = await Stagehand.create({ browser });
const [page] = await browser.context.pages();
await page.goto("https://example.com");
// Browser window stays open after the script exits
await stagehand.close();
await browser.close();
```
```python theme={null}
browser = await local_browser.launch(
keep_alive=True,
headless=False,
port=9222,
)
stagehand = await Stagehand.create(browser=browser)
page = (await browser.context.pages())[0]
await page.goto("https://example.com")
# Browser window stays open after the script exits
await stagehand.close()
await browser.close()
```
```go theme={null}
browser, err := stagehand.LaunchLocalBrowser(ctx, &stagehand.LocalBrowserLaunchOptions{
KeepAlive: true,
Headless: false,
Port: 9222,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{Browser: browser})
if err != nil {
return err
}
browserContext, err := browser.Context()
if err != nil {
return err
}
pages, err := browserContext.Pages(ctx)
if err != nil {
return err
}
if len(pages) == 0 {
return errors.New("Stagehand initialized without an active page")
}
page := pages[0]
if _, err := page.Goto(ctx, "https://example.com", nil); err != nil {
return err
}
// Browser window stays open after the program exits
return errors.Join(client.Close(ctx), browser.Close(ctx))
```
A temporary user data directory is deleted when a local browser shuts down. Set `userDataDir` (or turn on the preserve option) if you want the profile to survive.
#### Browserbase environment
On Browserbase, keep-alive keeps the cloud session active so you can reconnect later with `browserbase.connect()`. This is useful for long-running workflows that span multiple script executions.
Keeping sessions alive is available on the Browserbase Startup plan and above.
### Fixed CDP debugging port
Specify a fixed Chrome DevTools Protocol (CDP) debugging port instead of using a randomly assigned one.
```typescript theme={null}
import { localBrowser, Stagehand } from "@browserbasehq/stagehand";
const browser = await localBrowser.launch({ port: 9222 });
const stagehand = await Stagehand.create({ browser });
```
```python theme={null}
from stagehand import Stagehand, local_browser
browser = await local_browser.launch(port=9222)
stagehand = await Stagehand.create(browser=browser)
```
```go theme={null}
browser, err := stagehand.LaunchLocalBrowser(ctx, &stagehand.LocalBrowserLaunchOptions{Port: 9222})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
Stagehand picks a random free port when you do not set one.
### DOM settle timeout
Configure how long Stagehand waits for the DOM to stabilize before taking actions. The default is 5000 ms.
```typescript theme={null}
const browser = await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY });
const stagehand = await Stagehand.create({
browser,
domSettleTimeoutMs: 3000, // Wait up to 3 seconds for DOM to settle
});
```
```python theme={null}
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
dom_settle_timeout_ms=3000, # Wait up to 3 seconds for DOM to settle
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
domSettleTimeoutMs := 3000 // Wait up to 3 seconds for DOM to settle
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{APIKey: apiKey})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
DOMSettleTimeoutMs: &domSettleTimeoutMs,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
DOM settling applies to `act()` calls that take a natural-language instruction. Replaying an observed `Action` skips the wait, and `observe()` and `extract()` read a snapshot of the page as they find it.
#### What is DOM settling?
Before an instruction-based `act()` call runs, Stagehand waits for the page's network activity to go quiet, up to the timeout. That gives lazy-loaded content, JavaScript updates, and other dynamic rendering time to finish before the action targets an element.
#### When to adjust
Increase the DOM settle timeout for pages with:
* Heavy animations or transitions
* Lazy-loading or infinite scroll
* Dynamic JavaScript frameworks (React, Vue, Angular)
* Complex single-page applications
```typescript theme={null}
// For fast, static pages
const fastBrowser = await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY });
const fast = await Stagehand.create({
browser: fastBrowser,
domSettleTimeoutMs: 500, // Minimal wait
});
// For dynamic, animated pages
const dynamicBrowser = await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY });
const dynamic = await Stagehand.create({
browser: dynamicBrowser,
domSettleTimeoutMs: 5000, // Longer wait for stability
});
```
```python theme={null}
# For fast, static pages
fast_browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
fast = await Stagehand.create(
browser=fast_browser,
dom_settle_timeout_ms=500, # Minimal wait
)
# For dynamic, animated pages
dynamic_browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
dynamic = await Stagehand.create(
browser=dynamic_browser,
dom_settle_timeout_ms=5000, # Longer wait for stability
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{APIKey: apiKey})
if err != nil {
return err
}
settleTimeout := 500 // Fast, static pages
if os.Getenv("PAGE_PROFILE") == "dynamic" {
settleTimeout = 5000 // Dynamic, animated pages
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
DOMSettleTimeoutMs: &settleTimeout,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
Setting the DOM settle timeout too low may cause actions to fail on elements that aren't ready. Setting it too high increases execution time unnecessarily.
## Troubleshooting
* Verify your `BROWSERBASE_API_KEY` is read and passed to `browserbase.launch()`
* Check that your API key has the necessary permissions
* Ensure your Browserbase account has sufficient credits
* Remember that `browserbase.launch()` requires an API key: it fails without one
* Install Chrome or Chromium on your system
* Set the correct executable path for your Chrome installation, or set `CHROME_PATH`
* Check that required dependencies are installed (Linux: `libnss3-dev libatk-bridge2.0-dev libgtk-3-dev libxss1 libasound2`)
* If the extension fails to load, confirm nothing is stripping `--enable-unsafe-extension-debugging` from the launch arguments
* Increase the session timeout on `browserbase.launch()`
* Turn on keep-alive for long-running sessions
* Monitor session usage to avoid unexpected terminations
# Logging
Source: https://docs.stagehand.dev/v4/configuration/logging
Control terminal output and forward structured Stagehand logs
Configure logging through a single `logging` option on `Stagehand.create()`: a level, an output format, and a callback that receives each record. Use the language selector to see the exact shape your SDK accepts.
## Quick start
Choose your logging setup based on your environment:
```typescript theme={null}
import { localBrowser, Stagehand } from "@browserbasehq/stagehand";
// Development: full debug output, human-readable
const development = { level: "debug", format: "pretty" } as const;
// Production: standard logging, machine-readable, forwarded to your platform
const production = {
level: "info",
format: "json",
onLog: yourProductionLogger, // Send to Sentry, DataDog, or similar
} as const;
// Testing: warnings and errors only, no console noise
const testing = { level: "warn", onLog: yourTestLogger } as const;
// Create one instance with the configuration for this environment
const stagehand = await Stagehand.create({
browser: await localBrowser.launch(),
logging: development,
// restOfYourConfiguration...
});
```
```python theme={null}
from stagehand import Stagehand, local_browser
# Development: full debug output, human-readable
development = {"level": "debug", "format": "pretty"}
# Production: standard logging, machine-readable, forwarded to your platform
production = {
"level": "info",
"format": "json",
"on_log": your_production_logger, # Send to Sentry, DataDog, or similar
}
# Testing: warnings and errors only, no console noise
testing = {"level": "warn", "on_log": your_test_logger}
# Create one instance with the configuration for this environment
browser = await local_browser.launch()
stagehand = await Stagehand.create(
browser=browser,
logging=development,
# rest_of_your_configuration...
)
```
```go theme={null}
// Development: full debug output, human-readable
development := &stagehand.StagehandClientLoggingConfig{
Level: stagehand.StagehandClientLogLevelDebug,
Format: stagehand.StagehandClientLogFormatPretty,
}
// Production: standard logging, machine-readable, forwarded to your platform
production := &stagehand.StagehandClientLoggingConfig{
Level: stagehand.StagehandClientLogLevelInfo,
Format: stagehand.StagehandClientLogFormatJSON,
OnLog: yourProductionLogger, // Send to Sentry, DataDog, or similar
}
// Testing: warnings and errors only, no console noise
testing := &stagehand.StagehandClientLoggingConfig{
Level: stagehand.StagehandClientLogLevelWarn,
OnLog: yourTestLogger,
}
// Pick the configuration that matches the environment you are running in
logging := development
switch os.Getenv("APP_ENV") {
case "production":
logging = production
case "test":
logging = testing
}
browser, err := stagehand.LaunchLocalBrowser(ctx, &stagehand.LocalBrowserLaunchOptions{})
if err != nil {
return err
}
defer func() { err = errors.Join(err, browser.Close(ctx)) }()
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Logging: logging,
// restOfYourConfiguration...
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
***
## Operational logging
Real-time event logging during automation execution.
### Verbosity level
Control how much detail you see in logs. The level is always applied at the source, so the Stagehand runtime never generates or sends a record below your threshold. Every SDK sets that threshold the same way, and the default is `info`.
**Use for:** Development, debugging specific issues
```typescript theme={null}
const stagehand = await Stagehand.create({
browser,
logging: { level: "debug" }, // Maximum detail
// restOfYourConfiguration...
});
```
```python theme={null}
stagehand = await Stagehand.create(
browser=browser,
logging={"level": "debug"}, # Maximum detail
# rest_of_your_configuration...
)
```
```go theme={null}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Logging: &stagehand.StagehandClientLoggingConfig{
Level: stagehand.StagehandClientLogLevelDebug, // Maximum detail
},
// restOfYourConfiguration...
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
```
[stagehand] DEBUG Capturing DOM snapshot {"pageId":"page-1"}
[stagehand] DEBUG DOM contains 847 elements {"count":847}
[stagehand] DEBUG LLM inference started {"category":"llm"}
[stagehand] DEBUG LLM response {"selector":"#btn-submit","method":"click"}
[stagehand] INFO act completed successfully {"category":"action"}
```
**Use for:** Standard operations, staging, production
```typescript theme={null}
const stagehand = await Stagehand.create({
browser,
logging: { level: "info" }, // Default level
// restOfYourConfiguration...
});
```
```python theme={null}
stagehand = await Stagehand.create(
browser=browser,
logging={"level": "info"}, # Default level
# rest_of_your_configuration...
)
```
```go theme={null}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Logging: &stagehand.StagehandClientLoggingConfig{
Level: stagehand.StagehandClientLogLevelInfo, // Default level
},
// restOfYourConfiguration...
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
```
[stagehand] INFO act started {"category":"action"}
[stagehand] INFO act completed successfully {"category":"action"}
[stagehand] INFO extract started {"category":"extraction"}
[stagehand] INFO extract completed {"category":"extraction"}
```
**Use for:** Production with external monitoring, minimal noise, or handling secrets
```typescript theme={null}
const errorsOnly = { level: "error" } as const; // Errors only
const silent = { level: "off" } as const; // Nothing at all
const stagehand = await Stagehand.create({
browser,
logging: errorsOnly,
// restOfYourConfiguration...
});
```
```python theme={null}
errors_only = {"level": "error"} # Errors only
silent = {"level": "off"} # Nothing at all
stagehand = await Stagehand.create(
browser=browser,
logging=errors_only,
# rest_of_your_configuration...
)
```
```go theme={null}
// Errors only
errorsOnly := &stagehand.StagehandClientLoggingConfig{
Level: stagehand.StagehandClientLogLevelError,
}
// Nothing at all
silent := &stagehand.StagehandClientLoggingConfig{
Level: stagehand.StagehandClientLogLevelOff,
}
// Pick the configuration that matches how much output you want
logging := errorsOnly
if os.Getenv("STAGEHAND_SILENT") == "1" {
logging = silent
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Logging: logging,
// restOfYourConfiguration...
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
```
[stagehand] ERROR act failed: element not found {"category":"action"}
[stagehand] ERROR navigation timeout exceeded {"category":"navigation"}
```
Turning logging off suppresses the record callback as well as console output. Do this when handling passwords or other secrets.
***
### Log destinations
Send logs to your console or through your own callback. The record callback drives every destination, so anything your own code can write to is a valid sink. To forward records to an observability platform, see [External logging platforms](#external-logging-platforms).
Human-readable, single-line console output written to standard error.
**When to use:** Development and interactive debugging
```typescript theme={null}
// Enabled by default: level "info", format "pretty"
const stagehand = await Stagehand.create({
browser,
logging: { level: "info", format: "pretty" },
// restOfYourConfiguration...
});
```
```python theme={null}
# Enabled by default: level "info", format "pretty"
stagehand = await Stagehand.create(
browser=browser,
logging={"level": "info", "format": "pretty"},
# rest_of_your_configuration...
)
```
```go theme={null}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Logging: &stagehand.StagehandClientLoggingConfig{
Level: stagehand.StagehandClientLogLevelInfo,
Format: stagehand.StagehandClientLogFormatPretty,
},
// restOfYourConfiguration...
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
Each line is `[stagehand] LEVEL message {json data}`, with the data object omitted when empty. Stagehand writes it to standard error so it never mixes with your program's own standard output.
One JSON object per line, ready for log shippers and structured search.
**When to use:** Containers, CI, or anywhere a collector tails standard error
```typescript theme={null}
const stagehand = await Stagehand.create({
browser,
logging: { level: "info", format: "json" },
// restOfYourConfiguration...
});
```
```python theme={null}
stagehand = await Stagehand.create(
browser=browser,
logging={"level": "info", "format": "json"},
# rest_of_your_configuration...
)
```
```go theme={null}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Logging: &stagehand.StagehandClientLoggingConfig{
Level: stagehand.StagehandClientLogLevelInfo,
Format: stagehand.StagehandClientLogFormatJSON,
},
// restOfYourConfiguration...
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
```json theme={null}
{"level":"info","message":"act completed successfully","data":{"category":"action"}}
```
Your own callback receives every log record that passes the level filter, alongside the console output.
**When to use:** Development, debugging, or when you don't need querying
capabilities.
```typescript theme={null}
// Simple logger without parsing (for basic console output)
// The callback receives a `StagehandLog`; see the reference below for the shape.
const simpleLogger = (log: {
level: "debug" | "info" | "warn" | "error";
message: string;
data: Record;
}) => {
console.log(`[${log.level}] ${log.message}`);
// Optional: log raw structured data
if (Object.keys(log.data).length > 0) {
console.log(" Context:", log.data);
}
};
```
```python theme={null}
# Simple logger without parsing (for basic console output)
def simple_logger(log) -> None:
print(f"[{log.level.value}] {log.message}")
# Optional: log raw structured data
data = log.data.model_dump(mode="json")
if data:
print(" Context:", data)
```
```go theme={null}
// Simple logger without parsing (for basic console output)
func simpleLogger(entry stagehand.StagehandLog) {
fmt.Printf("[%s] %s\n", entry.Level, entry.Message)
// Optional: log raw structured data
if len(entry.Data) > 0 {
fmt.Println(" Context:", entry.Data)
}
}
```
Then pass the logger in your Stagehand instance:
```typescript theme={null}
const stagehand = await Stagehand.create({
browser: await localBrowser.launch(),
logging: {
level: "info",
onLog: simpleLogger,
},
// restOfYourConfiguration...
});
```
```python theme={null}
browser = await local_browser.launch()
stagehand = await Stagehand.create(
browser=browser,
logging={"level": "info", "on_log": simple_logger},
# rest_of_your_configuration...
)
```
```go theme={null}
browser, err := stagehand.LaunchLocalBrowser(ctx, &stagehand.LocalBrowserLaunchOptions{})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Logging: &stagehand.StagehandClientLoggingConfig{
OnLog: simpleLogger,
},
// restOfYourConfiguration...
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
The callback receives every record that passes the level gate, so do any finer filtering inside it. It runs in addition to the console output rather than instead of it: the level gates both and `off` silences both. Keep the level at the lowest severity you want to receive, and redirect standard error if the console itself needs to stay quiet.
***
### External logging platforms
The same log callback, pointed at an observability platform.
**When to use:** Production with Sentry, DataDog, CloudWatch, or a custom observability platform for centralized monitoring and error alerting.
#### Sentry
```typescript theme={null}
import * as Sentry from "@sentry/node";
const productionLogger = (log: {
level: "debug" | "info" | "warn" | "error";
message: string;
data: Record;
}) => {
// Send errors to Sentry
if (log.level === "error") {
Sentry.captureMessage(log.message, {
level: "error",
extra: log.data,
});
}
};
```
```python theme={null}
import sentry_sdk
def production_logger(log) -> None:
# Send errors to Sentry
if log.level.value == "error":
sentry_sdk.capture_message(
log.message,
level="error",
extras=log.data.model_dump(mode="json"),
)
```
```go theme={null}
func productionLogger(entry stagehand.StagehandLog) {
// Send errors to Sentry
if entry.Level != stagehand.StagehandLogLevelError {
return
}
sentry.WithScope(func(scope *sentry.Scope) {
// Data values are json.RawMessage. Pass them through so Sentry keeps each
// field's JSON type instead of recording it as quoted text.
for key, value := range entry.Data {
scope.SetExtra(key, value)
}
scope.SetLevel(sentry.LevelError)
sentry.CaptureMessage(entry.Message)
})
}
```
```typescript theme={null}
const stagehand = await Stagehand.create({
browser: await localBrowser.launch(),
logging: {
level: "info",
format: "json",
onLog: productionLogger,
},
// restOfYourConfiguration...
});
```
```python theme={null}
browser = await local_browser.launch()
stagehand = await Stagehand.create(
browser=browser,
logging={
"level": "info",
"format": "json",
"on_log": production_logger,
},
# rest_of_your_configuration...
)
```
```go theme={null}
browser, err := stagehand.LaunchLocalBrowser(ctx, &stagehand.LocalBrowserLaunchOptions{})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Logging: &stagehand.StagehandClientLoggingConfig{
Level: stagehand.StagehandClientLogLevelInfo,
Format: stagehand.StagehandClientLogFormatJSON,
OnLog: productionLogger,
},
// restOfYourConfiguration...
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
#### DataDog
```typescript theme={null}
import { datadogLogs } from "@datadog/browser-logs";
const productionLogger = (log: {
level: "debug" | "info" | "warn" | "error";
message: string;
data: Record;
}) => {
// Send all logs to DataDog
datadogLogs.logger.log(log.message, {
status: log.level === "error" ? "error" : "info",
service: "stagehand-automation",
...log.data,
});
};
```
```python theme={null}
from datadog_api_client import ApiClient, Configuration
from datadog_api_client.v2.api.logs_api import LogsApi
logs_api = LogsApi(ApiClient(Configuration()))
def production_logger(log) -> None:
# Send all logs to DataDog
logs_api.submit_log(body=[{
"message": log.message,
"status": "error" if log.level.value == "error" else "info",
"service": "stagehand-automation",
**log.data.model_dump(mode="json"),
}])
```
```go theme={null}
func productionLogger(entry stagehand.StagehandLog) {
// Send all logs to DataDog
status := "info"
if entry.Level == stagehand.StagehandLogLevelError {
status = "error"
}
payload := map[string]any{
"message": entry.Message,
"status": status,
"service": "stagehand-automation",
}
// Data values are json.RawMessage. Keep them raw so DataDog indexes numbers
// as numbers and strings as strings.
for key, value := range entry.Data {
payload[key] = value
}
_ = submitDataDogLog(payload)
}
```
```typescript theme={null}
const stagehand = await Stagehand.create({
browser: await localBrowser.launch(),
logging: {
level: "info",
format: "json",
onLog: productionLogger,
},
// restOfYourConfiguration...
});
```
```python theme={null}
browser = await local_browser.launch()
stagehand = await Stagehand.create(
browser=browser,
logging={
"level": "info",
"format": "json",
"on_log": production_logger,
},
# rest_of_your_configuration...
)
```
```go theme={null}
browser, err := stagehand.LaunchLocalBrowser(ctx, &stagehand.LocalBrowserLaunchOptions{})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Logging: &stagehand.StagehandClientLoggingConfig{
Level: stagehand.StagehandClientLogLevelInfo,
Format: stagehand.StagehandClientLogFormatJSON,
OnLog: productionLogger,
},
// restOfYourConfiguration...
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
Failures inside your callback are caught and reported on standard error, so a failing logger will not take down your automation. That covers exceptions and rejected promises in TypeScript and Python, and a panic in Go.
***
## File-based session logging
Route the log callback to a file to get a durable, per-session record of every Stagehand operation: `act`, `observe`, `extract`, LLM inference, and browser events.
### Setup
Pick a directory for your session logs and open a file per run:
```typescript theme={null}
import { createWriteStream } from "node:fs";
import { mkdirSync } from "node:fs";
mkdirSync("./stagehand-logs", { recursive: true });
const sessionId = new Date().toISOString().replace(/[:.]/g, "-");
const logFile = createWriteStream(`./stagehand-logs/${sessionId}.jsonl`, { flags: "a" });
```
```python theme={null}
from datetime import datetime, timezone
from pathlib import Path
Path("./stagehand-logs").mkdir(parents=True, exist_ok=True)
session_id = datetime.now(timezone.utc).isoformat().replace(":", "-").replace(".", "-")
log_file = open(f"./stagehand-logs/{session_id}.jsonl", "a", encoding="utf-8")
```
```go theme={null}
if err := os.MkdirAll("./stagehand-logs", 0o755); err != nil {
return err
}
startedAt := time.Now().UTC()
sessionID := strings.NewReplacer(":", "-", ".", "-").Replace(startedAt.Format(time.RFC3339Nano))
logFile, err := os.OpenFile(
fmt.Sprintf("./stagehand-logs/%s.jsonl", sessionID),
os.O_APPEND|os.O_CREATE|os.O_WRONLY,
0o600,
)
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "writing Stagehand logs to %s\n", logFile.Name())
```
### Usage
Write each record as one JSON line, then run your Stagehand script as normal:
```typescript theme={null}
import { finished } from "node:stream/promises";
import { localBrowser, Stagehand } from "@browserbasehq/stagehand";
const stagehand = await Stagehand.create({
browser: await localBrowser.launch(),
logging: {
level: "debug",
format: "pretty",
onLog(log) {
logFile.write(`${JSON.stringify(log)}\n`);
},
},
});
try {
// ... your automation
} finally {
await stagehand.close();
logFile.end();
await finished(logFile);
}
```
```python theme={null}
from stagehand import Stagehand, local_browser
browser = await local_browser.launch()
stagehand = await Stagehand.create(
browser=browser,
logging={
"level": "debug",
"format": "pretty",
"on_log": lambda log: print(log.model_dump_json(), file=log_file),
},
)
try:
... # your automation
finally:
await stagehand.close()
log_file.close()
```
```go theme={null}
defer func() { err = errors.Join(err, logFile.Close()) }()
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Logging: &stagehand.StagehandClientLoggingConfig{
Level: stagehand.StagehandClientLogLevelDebug,
Format: stagehand.StagehandClientLogFormatPretty,
OnLog: func(log stagehand.StagehandLog) {
_ = json.NewEncoder(logFile).Encode(log)
},
},
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
// ... your automation
```
### Viewing logs
Follow all logs as they happen:
```bash theme={null}
tail -f ./stagehand-logs/*.jsonl
```
Or filter by category as they stream:
```bash theme={null}
# LLM requests and responses only
tail -f ./stagehand-logs/*.jsonl | jq 'select(.data.category == "llm")'
# Action events only
tail -f ./stagehand-logs/*.jsonl | jq 'select(.data.category == "action")'
```
View unified output across every session file:
```bash theme={null}
cat ./stagehand-logs/*.jsonl | jq -r '"\(.level)\t\(.message)"'
```
Browse previous session logs:
```bash theme={null}
ls ./stagehand-logs/
# Output: 2026-01-06T14-30-45-000Z.jsonl 2026-01-06T15-45-12-000Z.jsonl
jq -r 'select(.level == "error")' ./stagehand-logs/2026-01-06T14-30-45-000Z.jsonl
```
### Log files
Because you own the sink, you decide how records are split. A common layout is one file per concern, keyed off the log category:
| File | Contents |
| - | - |
| `llm_events.jsonl` | LLM requests and responses for act, extract, and observe operations |
| `browser_events.jsonl` | Navigation, snapshot, and page lifecycle events |
| `stagehand.jsonl` | Every record, unfiltered |
This is especially useful for debugging long workflows where you need to trace the full sequence of LLM decisions and browser actions after the fact.
***
## LLM inference debugging
**Development only** - Produces large volumes of output and contains page content. Do not use in production.
Run at the `debug` level to see the complete inference path: the snapshot that was captured, the prompt category, the model's chosen element, and the token counts for each call.
```typescript theme={null}
const stagehand = await Stagehand.create({
browser: await localBrowser.launch(),
logging: {
level: "debug",
format: "json",
onLog(log) {
// Persist only the LLM records for offline analysis
if (log.data.category === "llm") {
inferenceFile.write(`${JSON.stringify(log)}\n`);
}
},
},
});
```
```python theme={null}
def capture_inference(log) -> None:
# Persist only the LLM records for offline analysis
data = log.data.model_dump(mode="json")
if data.get("category") == "llm":
print(log.model_dump_json(), file=inference_file)
browser = await local_browser.launch()
stagehand = await Stagehand.create(
browser=browser,
logging={
"level": "debug",
"format": "json",
"on_log": capture_inference,
},
)
```
```go theme={null}
func captureInference(inferenceFile *os.File) func(stagehand.StagehandLog) {
return func(entry stagehand.StagehandLog) {
// Persist only the LLM records for offline analysis
if category, ok := entry.Data["category"]; ok && string(category) == `"llm"` {
_ = json.NewEncoder(inferenceFile).Encode(entry)
}
}
}
browser, err := stagehand.LaunchLocalBrowser(ctx, &stagehand.LocalBrowserLaunchOptions{})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Logging: &stagehand.StagehandClientLoggingConfig{
Level: stagehand.StagehandClientLogLevelDebug,
OnLog: captureInference(inferenceFile),
},
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
Debug-level records include:
Emitted before each LLM call, carrying the operation and prompt metadata:
```json theme={null}
{
"level": "debug",
"message": "inference started",
"data": {
"category": "llm",
"operation": "act",
"model": "openai/gpt-5.4-mini"
}
}
```
Emitted when the model returns, carrying the selected element:
```json theme={null}
{
"level": "debug",
"message": "inference completed",
"data": {
"category": "llm",
"operation": "act",
"elementId": "0-1183",
"method": "click"
}
}
```
Emitted once per operation with the token and latency accounting:
```json theme={null}
{
"level": "debug",
"message": "act inference usage",
"data": {
"category": "llm",
"promptTokens": 3451,
"completionTokens": 45,
"inferenceTimeMs": 951
}
}
```
Token counters are also aggregated across the whole session. See [Observability](/v4/configuration/observability#real-time-metrics-%26-monitoring) for the cumulative view.
***
## Reference
### Logging configuration
Pass all logging options as a single object to `Stagehand.create()`:
```typescript theme={null}
// The shape of the `logging` value in the Stagehand.create() options.
type StagehandClientLoggingConfig = {
level?: "off" | "error" | "warn" | "info" | "debug"; // default: "info"
format?: "pretty" | "json"; // default: "pretty"
onLog?: (log: StagehandLog) => void | Promise; // default: undefined
};
```
```python theme={null}
stagehand = await Stagehand.create(
browser=browser,
# ... your other configurations (browser, model, etc.)
logging={
"level": "info", # "off" | "error" | "warn" | "info" | "debug"
"format": "pretty", # "pretty" | "json"
"on_log": None, # Callable[[StagehandLog], None | Awaitable[None]]
},
)
```
```go theme={null}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Logging: &stagehand.StagehandClientLoggingConfig{
Level: stagehand.StagehandClientLogLevelInfo,
Format: stagehand.StagehandClientLogFormatPretty,
OnLog: func(entry stagehand.StagehandLog) {},
},
// ... your other configurations (Browser, Model, etc.)
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
| Option | Default | Description |
| - | - | - |
| level | `info` | Minimum level to emit: `off`, `error`, `warn`, `info`, or `debug` |
| format | `pretty` | Console rendering: human-readable lines or one JSON object per line |
| log callback | none | Receives every record that passes the level filter, alongside the console output |
The level is also forwarded to the Stagehand runtime, so suppressed records are never generated or sent over the wire. Raising it to `debug` increases both log volume and message traffic.
### Log structure
Each log entry follows a structured format:
```typescript theme={null}
interface StagehandLog {
level: "debug" | "info" | "warn" | "error"; // Severity
message: string; // "act completed successfully"
data: Record; // Structured metadata, JSON-serializable
}
```
```python theme={null}
class StagehandLog(BaseModel):
level: StagehandLogLevel # "debug" | "info" | "warn" | "error"
message: str # "act completed successfully"
data: StagehandLogData # Structured metadata, JSON-serializable
```
```go theme={null}
type StagehandLog struct {
Level StagehandLogLevel // "debug" | "info" | "warn" | "error"
Message string // "act completed successfully"
Data StagehandLogData // map[string]json.RawMessage
}
```
Unlike v3's `LogLine`, `data` is a flat JSON object: values are already typed, so there is no `{ value, type }` wrapper to parse. Fields such as `category` and `timestamp` appear inside `data` when the emitting operation provides them.
```json theme={null}
{
"level": "info",
"message": "act completed successfully",
"data": {
"category": "action",
"selector": "xpath=/html[1]/body[1]/button[1]",
"executionTimeMs": 1250
}
}
```
```json theme={null}
{
"level": "debug",
"message": "inference completed",
"data": {
"category": "llm",
"model": "openai/gpt-5.4-mini",
"promptTokens": 3451,
"completionTokens": 45
}
}
```
```json theme={null}
{
"level": "error",
"message": "action failed: element not found",
"data": {
"category": "action",
"selector": "#missing-btn",
"url": "https://example.com/form"
}
}
```
***
## Next steps
Now that you have logging configured, explore additional debugging and monitoring tools in [the Observability guide](/v4/configuration/observability):
Export OpenTelemetry spans for every Stagehand operation to your own collector, with trace context propagated across the SDK and runtime boundary.
Monitor token usage and performance in real-time. Track costs per operation, identify expensive calls, and optimize resource usage.
Run at the debug level to see exactly what Stagehand sent to the model and why the model chose each action.
Watch your automation visually with session recordings, network monitoring, and real-time browser inspection (Browserbase only).
# Models
Source: https://docs.stagehand.dev/v4/configuration/models
Use any LLM with Stagehand
**New: automatic model selection.** Omit `model` and let the Model Gateway pick one for every `act`, `extract`, and `observe` call. One key, one bill, no provider accounts needed.
## Model Gateway
Model Gateway lets you use Stagehand without wiring up model providers yourself. When you omit `model`, Browserbase automatically selects a model for each `act`, `extract`, and `observe` call. You can still provide a model explicitly when you want to pin one.
### Setup
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
});
```
```python theme={null}
import os
from stagehand import Stagehand, browserbase
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(browser=browser)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
// No model configured: requests route through Model Gateway
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
When no model is configured, Stagehand routes inference through Model Gateway without a `model` field and Browserbase selects one automatically. Selection happens server-side on every call, so your code never pins a model name and picks up new models as Browserbase adds them.
What you pass decides where a call goes:
| Configuration | Where inference runs |
| - | - |
| No `model` | Model Gateway, with Browserbase selecting the model per call |
| `model` with no `apiKey` | Model Gateway, pinned to that model |
| `model` with an `apiKey` | Straight to that provider, bypassing Gateway |
| A client-side LLM callback | Your callback, bypassing Gateway. See [bring your own LLM](#custom-models) |
Model Gateway requires Browserbase-hosted browsers. It does not work with local browsers, because those have no Browserbase session to bill and authorize against.
Omitting `model` from `Stagehand.create()` enables routing for that Stagehand instance. Selection itself happens per call, so one run can use different models at different steps. [Per-call overrides](#per-call-model-overrides) enable selecting a specific model instead of routing it.
Model Gateway rejects `stopSequences`. Pin a model with its own provider API key when you need them.
### Switching models
With Model Gateway, switching between providers is a config change: no new accounts, API keys, or code rewiring required.
```typescript theme={null}
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
model: { modelName: "openai/gpt-5.6-sol" },
});
```
```python theme={null}
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
model="openai/gpt-5.6-sol",
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
// No model API key: requests route through Model Gateway
model := stagehand.ModelConfig{ModelName: "openai/gpt-5.6-sol"}
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Model: &model,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
```typescript theme={null}
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
model: { modelName: "anthropic/claude-sonnet-5" },
});
```
```python theme={null}
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
model="anthropic/claude-sonnet-5",
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
// No model API key: requests route through Model Gateway
model := stagehand.ModelConfig{ModelName: "anthropic/claude-sonnet-5"}
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Model: &model,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
```typescript theme={null}
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
model: { modelName: "google/gemini-3.8-flash" },
});
```
```python theme={null}
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
model="google/gemini-3.8-flash",
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
// No model API key: requests route through Model Gateway
model := stagehand.ModelConfig{ModelName: "google/gemini-3.8-flash"}
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Model: &model,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
### Key benefits
* **One key, one bill:** LLM inference, browser infrastructure, and caching all run through your Browserbase API key.
* **Market-price tokens:** Browserbase charges the same price as going direct to the provider. No markup.
* **Built-in reliability:** Browserbase handles retries, backoff, and rate limits.
* **No tier-gating:** Access new models immediately without hitting provider spend thresholds.
* **Action caching:** Model Gateway works with Stagehand's [managed action caching](/v4/best-practices/caching), so repeated steps are reused instead of re-run. Both features run off the same Browserbase session, so turning on `cache` costs you nothing extra to set up.
### Supported providers
| Provider | Example Model |
| - | - |
| OpenAI | `openai/gpt-5.6-sol` |
| Anthropic | `anthropic/claude-sonnet-5` |
| Google | `google/gemini-3.8-flash` |
Need a provider that isn't listed? [Reach out](https://www.browserbase.com/contact): Browserbase is happy to work with teams on additional model support.
***
## Configuration setup
### Quick start
Read your provider key from the environment and pass it on the model configuration. Stagehand does not read environment variables for you.
Get started with Google Gemini:
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
model: {
modelName: "google/gemini-3.8-flash",
},
});
```
```python theme={null}
import os
from stagehand import Stagehand, browserbase
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
model="google/gemini-3.8-flash",
model_api_key=os.environ["GOOGLE_GENERATIVE_AI_API_KEY"],
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
modelAPIKey := os.Getenv("GOOGLE_GENERATIVE_AI_API_KEY")
model := stagehand.ModelConfig{
ModelName: "google/gemini-3.8-flash",
APIKey: &modelAPIKey,
}
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Model: &model,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
Model names for the providers below carry a `provider/` prefix, and the provider must be one of the five. Stagehand ships a list of known model IDs per provider and validates the full name when you call `Stagehand.create()` or pass a per-call override, so a name it does not recognize fails before any request reaches the provider. Upgrade the SDK to pick up newly released models. The prefix is never optional: to reach an Azure OpenAI deployment, a self-hosted model, or anything else outside those five providers, use the [bring-your-own-LLM callback](#custom-models).
***
### First-class models
Use any model from the following supported providers.
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
model: {
modelName: "google/gemini-3.8-flash",
},
});
```
```python theme={null}
import os
from stagehand import Stagehand, browserbase
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
model="google/gemini-3.8-flash",
model_api_key=os.environ["GOOGLE_GENERATIVE_AI_API_KEY"],
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
modelAPIKey := os.Getenv("GOOGLE_GENERATIVE_AI_API_KEY")
model := stagehand.ModelConfig{
ModelName: "google/gemini-3.8-flash",
APIKey: &modelAPIKey,
}
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Model: &model,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
Commonly used: `google/gemini-3.8-flash`, `google/gemini-3.7-flash`, `google/gemini-3.6-flash`, `google/gemini-3.5-flash`, `google/gemini-3.1-pro-preview`.
[View all supported Google models →](https://ai.google.dev/gemini-api/docs/models)
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
model: {
modelName: "anthropic/claude-sonnet-5",
},
});
```
```python theme={null}
import os
from stagehand import Stagehand, browserbase
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
model="anthropic/claude-sonnet-5",
model_api_key=os.environ["ANTHROPIC_API_KEY"],
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
modelAPIKey := os.Getenv("ANTHROPIC_API_KEY")
model := stagehand.ModelConfig{
ModelName: "anthropic/claude-sonnet-5",
APIKey: &modelAPIKey,
}
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Model: &model,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
Commonly used: `anthropic/claude-fable-5-1`, `anthropic/claude-opus-5`, `anthropic/claude-sonnet-5`, `anthropic/claude-haiku-4-5`, `anthropic/claude-sonnet-4-6`.
[View all supported Anthropic models →](https://docs.anthropic.com/en/docs/models-overview)
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
model: {
modelName: "openai/gpt-5.6-sol",
},
});
```
```python theme={null}
import os
from stagehand import Stagehand, browserbase
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
model="openai/gpt-5.6-sol",
model_api_key=os.environ["OPENAI_API_KEY"],
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
modelAPIKey := os.Getenv("OPENAI_API_KEY")
model := stagehand.ModelConfig{
ModelName: "openai/gpt-5.6-sol",
APIKey: &modelAPIKey,
}
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Model: &model,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
Commonly used: `openai/gpt-6-astra`, `openai/gpt-5.6-sol`, `openai/gpt-5.6-terra`, `openai/gpt-5.6-luna`, `openai/gpt-5.5`, `openai/gpt-5.4`.
OpenAI models are called through the Responses API.
[View all supported OpenAI models →](https://platform.openai.com/docs/models)
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
model: {
modelName: "groq/llama-3.3-70b-versatile",
},
});
```
```python theme={null}
import os
from stagehand import Stagehand, browserbase
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
model="groq/llama-3.3-70b-versatile",
model_api_key=os.environ["GROQ_API_KEY"],
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
modelAPIKey := os.Getenv("GROQ_API_KEY")
model := stagehand.ModelConfig{
ModelName: "groq/llama-3.3-70b-versatile",
APIKey: &modelAPIKey,
}
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Model: &model,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
Commonly used: `groq/llama-3.3-70b-versatile`, `groq/openai/gpt-oss-120b`, `groq/moonshotai/kimi-k2-instruct-0905`, `groq/qwen/qwen3-32b`.
[View all supported Groq models →](https://console.groq.com/docs/models)
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
model: {
modelName: "cerebras/gpt-oss-120b",
},
});
```
```python theme={null}
import os
from stagehand import Stagehand, browserbase
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
model="cerebras/gpt-oss-120b",
model_api_key=os.environ["CEREBRAS_API_KEY"],
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
modelAPIKey := os.Getenv("CEREBRAS_API_KEY")
model := stagehand.ModelConfig{
ModelName: "cerebras/gpt-oss-120b",
APIKey: &modelAPIKey,
}
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Model: &model,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
Commonly used: `cerebras/gpt-oss-120b`, `cerebras/qwen-3-235b-a22b-instruct-2507`, `cerebras/zai-glm-4.7`, `cerebras/llama3.1-8b`.
[View all supported Cerebras models →](https://inference-docs.cerebras.ai/models/overview)
***
### Custom models
Any provider Stagehand does not call natively is supported by bringing your own LLM. Instead of a model name, pass a function that Stagehand calls whenever it needs an inference. Your function runs in your process, on your machine, with your own SDKs and credentials: Amazon Bedrock, Cohere, Azure OpenAI, a self-hosted model, anything you can reach from code.
Stagehand sends a provider-neutral request (messages, system prompt, temperature, and a response format) and expects a matching result back. When the response format is a JSON schema, return the parsed object in the structured content field.
Install your provider's SDK.
```bash theme={null}
pnpm add @aws-sdk/client-bedrock-runtime
# npm install @aws-sdk/client-bedrock-runtime
# yarn add @aws-sdk/client-bedrock-runtime
# bun add @aws-sdk/client-bedrock-runtime
```
```bash theme={null}
pip install boto3
```
```bash theme={null}
go get github.com/aws/aws-sdk-go-v2/service/bedrockruntime
```
```typescript theme={null}
import {
BedrockRuntimeClient,
ConverseCommand,
} from "@aws-sdk/client-bedrock-runtime";
const bedrock = new BedrockRuntimeClient({ region: "us-east-1" });
// The shape Stagehand passes to your callback. The TypeScript SDK does not
// re-export `LLMGenerateParams` yet, so spell out the fields you read.
type LLMContentBlock =
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string };
type LLMGenerateParams = {
messages: Array<{
role: "user" | "assistant";
content: LLMContentBlock | LLMContentBlock[];
}>;
systemPrompt?: string;
temperature?: number;
responseFormat?:
| { type: "text" }
| { type: "json_schema"; name: string; schema: unknown };
};
// A message's content is one block or an array of them. Converse takes a list
// of content blocks, so map text to a text block and images (which
// `extract({ screenshot: true })` sends) to Converse's image block.
function messageContent(content: LLMContentBlock | LLMContentBlock[]) {
const blocks = Array.isArray(content) ? content : [content];
return blocks.map((block) =>
block.type === "text"
? { text: block.text }
: {
image: {
format: block.mimeType.replace("image/", ""),
source: { bytes: Buffer.from(block.data, "base64") },
},
},
);
}
async function generateWithBedrock(params: LLMGenerateParams) {
if (params.responseFormat?.type !== "json_schema") {
throw new TypeError("Stagehand only issues structured generations");
}
const response = await bedrock.send(new ConverseCommand({
modelId: "amazon.nova-pro-v1:0",
system: params.systemPrompt ? [{ text: params.systemPrompt }] : undefined,
messages: params.messages.map((message) => ({
role: message.role,
content: messageContent(message.content),
})),
}));
const text = response.output?.message?.content?.[0]?.text ?? "";
return {
role: "assistant",
content: { type: "text", text },
outputFormat: "json_schema",
structuredContent: JSON.parse(text),
};
}
```
```python theme={null}
import base64
import json
import boto3
bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")
def content_block(block):
# Each block is a LLMTextContent or LLMImageContent under `.root`. Converse
# takes its own content blocks, so map text to a text block and images
# (which `extract(screenshot=True)` sends) to Converse's image block.
if block.root.type == "text":
return {"text": block.root.text}
return {
"image": {
"format": block.root.mime_type.removeprefix("image/"),
"source": {"bytes": base64.b64decode(block.root.data)},
}
}
def message_content(message):
# A message's content is one block or a list of them.
content = message.content
blocks = content if isinstance(content, list) else [content]
return [content_block(block) for block in blocks]
async def generate_with_bedrock(params):
response = bedrock.converse(
modelId="amazon.nova-pro-v1:0",
system=[{"text": params.system_prompt}] if params.system_prompt else [],
messages=[
{"role": message.role.value, "content": message_content(message)}
for message in params.messages
],
)
text = response["output"]["message"]["content"][0]["text"]
return LLMStructuredGenerateResult.model_validate({
"role": "assistant",
"content": {"type": "text", "text": text},
"output_format": "json_schema",
"structured_content": json.loads(text),
})
```
```go theme={null}
// A message's content is a slice of blocks, each one text, image, tool-use, or
// tool-result. Converse takes its own content blocks, so map text to a text
// block and images (which extract sends when Screenshot is true) to an image
// block.
func messageContent(message stagehand.LLMMessage) ([]types.ContentBlock, error) {
blocks := make([]types.ContentBlock, 0, len(message.Content))
for _, block := range message.Content {
if content, ok := block.AsText(); ok {
blocks = append(blocks, &types.ContentBlockMemberText{Value: content.Text})
}
if content, ok := block.AsImage(); ok {
data, err := base64.StdEncoding.DecodeString(content.Data)
if err != nil {
return nil, err
}
blocks = append(blocks, &types.ContentBlockMemberImage{Value: types.ImageBlock{
Format: types.ImageFormat(strings.TrimPrefix(content.MIMEType, "image/")),
Source: &types.ImageSourceMemberBytes{Value: data},
}})
}
}
return blocks, nil
}
func generateWithBedrock(
ctx context.Context,
params stagehand.LLMGenerateParams,
) (stagehand.LLMGenerateResult, error) {
request, ok := params.AsStructured()
if !ok {
return stagehand.LLMGenerateResult{}, errors.New("Stagehand only issues structured generations")
}
messages := make([]types.Message, 0, len(request.Messages))
for _, message := range request.Messages {
content, err := messageContent(message)
if err != nil {
return stagehand.LLMGenerateResult{}, err
}
messages = append(messages, types.Message{
Role: types.ConversationRole(message.Role),
Content: content,
})
}
response, err := bedrock.Converse(ctx, &bedrockruntime.ConverseInput{
ModelId: aws.String("amazon.nova-pro-v1:0"),
Messages: messages,
System: systemBlocks(request.SystemPrompt),
})
if err != nil {
return stagehand.LLMGenerateResult{}, err
}
text := responseText(response)
return stagehand.StructuredGenerateResult(stagehand.LLMStructuredGenerateResult{
Role: stagehand.LLMRoleAssistant,
Content: stagehand.LLMMessageContent{stagehand.TextContentBlock(stagehand.LLMTextContent{Text: text})},
StructuredContent: json.RawMessage(text),
}), nil
}
```
```typescript theme={null}
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
model: { generate: generateWithBedrock },
});
```
```python theme={null}
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
model=generate_with_bedrock,
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Generate: generateWithBedrock,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
Install your provider's SDK.
```bash theme={null}
pnpm add openai
# npm install openai
# yarn add openai
# bun add openai
```
```bash theme={null}
pip install openai
```
```bash theme={null}
go get github.com/openai/openai-go
```
```typescript theme={null}
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
// The shape Stagehand passes to your callback. The TypeScript SDK does not
// re-export `LLMGenerateParams` yet, so spell out the fields you read.
type LLMContentBlock =
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string };
type LLMGenerateParams = {
messages: Array<{
role: "user" | "assistant";
content: LLMContentBlock | LLMContentBlock[];
}>;
systemPrompt?: string;
temperature?: number;
responseFormat?:
| { type: "text" }
| { type: "json_schema"; name: string; schema: unknown };
};
// A message's content is one block or an array of them. The Responses API takes
// input parts, so map text to an input text part and images (which
// `extract({ screenshot: true })` sends) to an input image data URL.
function messageContent(content: LLMContentBlock | LLMContentBlock[]) {
const blocks = Array.isArray(content) ? content : [content];
return blocks.map((block) =>
block.type === "text"
? { type: "input_text", text: block.text }
: {
type: "input_image",
image_url: `data:${block.mimeType};base64,${block.data}`,
detail: "auto",
},
);
}
async function generateWithOpenAI(params: LLMGenerateParams) {
if (params.responseFormat?.type !== "json_schema") {
throw new TypeError("Stagehand only issues structured generations");
}
const response = await openai.responses.create({
model: "gpt-5.6-sol",
instructions: params.systemPrompt,
input: params.messages.map((message) => ({
role: message.role,
content: messageContent(message.content),
})),
temperature: params.temperature,
text: {
format: {
type: "json_schema",
name: params.responseFormat.name,
schema: params.responseFormat.schema,
strict: true,
},
},
});
return {
role: "assistant",
content: { type: "text", text: response.output_text },
outputFormat: "json_schema",
structuredContent: JSON.parse(response.output_text),
};
}
```
```python theme={null}
import os
import json
from openai import AsyncOpenAI
from stagehand import LLMStructuredGenerateResult
openai = AsyncOpenAI(api_key=os.environ["OPENAI_API_KEY"])
def content_part(block):
# Each block is a LLMTextContent or LLMImageContent under `.root`. The
# Responses API takes input parts, so map text to an input text part and
# images (which `extract(screenshot=True)` sends) to an input image data URL.
if block.root.type == "text":
return {"type": "input_text", "text": block.root.text}
return {
"type": "input_image",
"image_url": f"data:{block.root.mime_type};base64,{block.root.data}",
"detail": "auto",
}
def message_content(message):
# A message's content is one block or a list of them.
content = message.content
blocks = content if isinstance(content, list) else [content]
return [content_part(block) for block in blocks]
async def generate_with_openai(params):
response_format = params.response_format
response = await openai.responses.create(
model="gpt-5.6-sol",
instructions=params.system_prompt,
input=[
{"role": message.role.value, "content": message_content(message)}
for message in params.messages
],
temperature=params.temperature,
text={
"format": {
"type": "json_schema",
"name": response_format.name,
"schema": response_format.schema_.model_dump(),
"strict": True,
}
},
)
return LLMStructuredGenerateResult.model_validate({
"role": "assistant",
"content": {"type": "text", "text": response.output_text},
"output_format": "json_schema",
"structured_content": json.loads(response.output_text),
})
```
```go theme={null}
func generateWithOpenAI(
ctx context.Context,
params stagehand.LLMGenerateParams,
) (stagehand.LLMGenerateResult, error) {
request, ok := params.AsStructured()
if !ok {
return stagehand.LLMGenerateResult{}, errors.New("Stagehand only issues structured generations")
}
text, err := callOpenAI(ctx, callOpenAIInput{
Model: "gpt-5.6-luna",
Instructions: request.SystemPrompt,
Messages: request.Messages,
Temperature: request.Temperature,
SchemaName: request.ResponseFormat.Name,
Schema: request.ResponseFormat.Schema,
})
if err != nil {
return stagehand.LLMGenerateResult{}, err
}
return stagehand.StructuredGenerateResult(stagehand.LLMStructuredGenerateResult{
Role: stagehand.LLMRoleAssistant,
Content: stagehand.LLMMessageContent{stagehand.TextContentBlock(stagehand.LLMTextContent{Text: text})},
StructuredContent: json.RawMessage(text),
}), nil
}
```
Reporting token usage is optional. To report it, add a `usage` field to the result and map your provider's own usage field names onto Stagehand's: input tokens, output tokens, and total tokens are required, while reasoning tokens and cached input tokens are optional. Providers spell these differently, so read the names off your provider's response type rather than assuming they match Stagehand's.
```typescript theme={null}
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
model: { generate: generateWithOpenAI },
});
```
```python theme={null}
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
model=generate_with_openai,
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Generate: generateWithOpenAI,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
The pattern is the same for every provider: implement one function, wire it in, done. Your callback never crosses the wire; Stagehand records only that the model lives on the client and calls back to you over the same connection.
Install your provider's SDK.
```typescript theme={null}
// The shape Stagehand passes to your callback. The TypeScript SDK does not
// re-export `LLMGenerateParams` yet, so spell out the fields you read.
type LLMGenerateParams = {
messages: Array<{ role: "user" | "assistant"; content: unknown }>;
systemPrompt?: string;
temperature?: number;
responseFormat?:
| { type: "text" }
| { type: "json_schema"; name: string; schema: unknown };
};
async function generateWithYourProvider(params: LLMGenerateParams) {
// params.messages Conversation so far
// params.systemPrompt System instructions
// params.temperature Sampling temperature, when set
// params.responseFormat { type: "json_schema", name, schema } for act/observe/extract
const text = await callYourProvider(params);
return {
role: "assistant",
content: { type: "text", text },
outputFormat: "json_schema",
structuredContent: JSON.parse(text),
};
}
```
```python theme={null}
async def generate_with_your_provider(params):
# params.messages Conversation so far
# params.system_prompt System instructions
# params.temperature Sampling temperature, when set
# params.response_format JSON schema request for act/observe/extract
text = await call_your_provider(params)
return LLMStructuredGenerateResult.model_validate({
"role": "assistant",
"content": {"type": "text", "text": text},
"output_format": "json_schema",
"structured_content": json.loads(text),
})
```
```go theme={null}
func generateWithYourProvider(
ctx context.Context,
params stagehand.LLMGenerateParams,
) (stagehand.LLMGenerateResult, error) {
// params.AsStructured() yields the JSON schema request act/observe/extract send
// request.Messages Conversation so far
// request.SystemPrompt System instructions
// request.Temperature Sampling temperature, when set
// request.ResponseFormat Name plus the JSON Schema to satisfy
request, ok := params.AsStructured()
if !ok {
return stagehand.LLMGenerateResult{}, errors.New("expected a structured generation")
}
text, err := callYourProvider(ctx, request)
if err != nil {
return stagehand.LLMGenerateResult{}, err
}
return stagehand.StructuredGenerateResult(stagehand.LLMStructuredGenerateResult{
Role: stagehand.LLMRoleAssistant,
Content: stagehand.LLMMessageContent{stagehand.TextContentBlock(stagehand.LLMTextContent{Text: text})},
StructuredContent: json.RawMessage(text),
}), nil
}
```
A message's content is one content block or an array of them. Text blocks carry a string, and image blocks carry base64 data plus a MIME type. [`extract()` with the screenshot option](/v4/basics/extract) sends a viewport screenshot as an image block, so map image blocks onto your provider's own image format. A callback that reads only the text blocks answers a visual extraction from the accessibility tree alone.
```typescript theme={null}
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
model: { generate: generateWithYourProvider },
});
```
```python theme={null}
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
model=generate_with_your_provider,
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Generate: generateWithYourProvider,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
The result is validated against the response format Stagehand asked for. If you return text when a JSON schema was requested, or structured content that does not match the schema, the call fails loudly rather than silently degrading.
***
## Choose a model
Different models excel at different tasks. The picks below follow [Browserbase Benchmark v2](https://www.stagehand.dev/evals).
Live accuracy, cost, and speed comparisons across models and harnesses.
**Quick recommendations**
| Use Case | Recommended Model | Why |
| - | - | - |
| **Production** | `openai/gpt-5.6-sol` | Strong accuracy without a high bill |
| **Intelligence** | `openai/gpt-6-astra` | Best accuracy on hard tasks |
| **Speed** | `openai/gpt-5.6-luna` | Fastest response times |
| **Cost** | `openai/gpt-5.6-luna` | Lowest cost per task |
| **Local/offline** | Bring your own LLM callback | No API costs, full control |
***
## Advanced options
### Per-call model overrides
Every primitive accepts a model configuration for a single call, so you can run cheap inference by default and reach for a stronger model only where it matters:
```typescript theme={null}
const PricingSchema = z.object({ summary: z.string() });
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
model: {
modelName: "google/gemini-3.8-flash",
},
});
// Uses the instance model
await stagehand.act("click the login button");
// Uses a stronger model for one hard extraction
const { data } = await stagehand.extract("summarize the pricing table", PricingSchema, {
model: {
modelName: "anthropic/claude-sonnet-5",
apiKey: process.env.ANTHROPIC_API_KEY,
},
});
console.log(data.summary);
```
```python theme={null}
import os
from pydantic import BaseModel
from stagehand import ModelConfig, Stagehand, browserbase
class Pricing(BaseModel):
summary: str
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
model="google/gemini-3.8-flash",
model_api_key=os.environ["GOOGLE_GENERATIVE_AI_API_KEY"],
)
# Uses the instance model
await stagehand.act("click the login button")
# Uses a stronger model for one hard extraction
data = (await stagehand.extract(
"summarize the pricing table",
Pricing,
model=ModelConfig(
model_name="anthropic/claude-sonnet-5",
api_key=os.environ["ANTHROPIC_API_KEY"],
),
)).data
print(data.summary)
```
```go theme={null}
type pricing struct {
Summary string `json:"summary"`
}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
googleKey := os.Getenv("GOOGLE_GENERATIVE_AI_API_KEY")
instanceModel := stagehand.ModelConfig{
ModelName: "google/gemini-3.8-flash",
APIKey: &googleKey,
}
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Model: &instanceModel,
})
if err != nil {
return err
}
// Uses the instance model
if _, err := client.Act(ctx, stagehand.ActInstruction("click the login button"), nil); err != nil {
return err
}
// Uses a stronger model for one hard extraction
anthropicKey := os.Getenv("ANTHROPIC_API_KEY")
strongModel := stagehand.ModelConfig{
ModelName: "anthropic/claude-sonnet-5",
APIKey: &anthropicKey,
}
extracted, err := stagehand.Extract[pricing](
ctx,
client,
"summarize the pricing table",
&stagehand.StagehandClientExtractOptions{
ExtractOptions: stagehand.ExtractOptions{Model: &strongModel},
},
)
if err != nil {
return err
}
fmt.Println(extracted.Data.Summary)
```
Model configuration is deliberately excluded from the [cache key](/v4/best-practices/caching), so switching models per call does not invalidate cached results.
***
### Custom headers
Some enterprise gateways require extra headers on every model request. Attach them to the model configuration:
```typescript theme={null}
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
model: {
modelName: "openai/gpt-5.6-sol",
headers: { "x-tenant-id": "acme" },
},
});
```
```python theme={null}
import os
from stagehand import Stagehand, browserbase
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
model="openai/gpt-5.6-sol",
model_api_key=os.environ["OPENAI_API_KEY"],
model_headers={"x-tenant-id": "acme"},
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
modelAPIKey := os.Getenv("OPENAI_API_KEY")
model := stagehand.ModelConfig{
ModelName: "openai/gpt-5.6-sol",
APIKey: &modelAPIKey,
Headers: stagehand.ModelConfigHeaders{"x-tenant-id": "acme"},
}
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Model: &model,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
There is no base URL option. A model configuration always names one of the five supported providers, so Azure OpenAI deployments, self-hosted models, and any other custom endpoint go through the [bring-your-own-LLM callback](#custom-models) instead, where you own the client, the transport, and the credentials.
***
### Extending your LLM client
For advanced use cases like custom retries or caching logic, wrap your generate callback. Because the callback is ordinary code in your process, you can layer whatever behavior you need around it:
```typescript theme={null}
// Generic in the callback's own params and result, so the wrapper stays
// interchangeable with the callback it wraps.
function withRetries(
generate: (params: Params) => Promise,
attempts = 3,
) {
return async (params: Params) => {
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await generate(params);
} catch (error) {
if (attempt === attempts) throw error;
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
}
}
throw new Error("withRetries needs at least one attempt");
};
}
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
model: { generate: withRetries(generateWithOpenAI) },
});
```
```python theme={null}
import asyncio
import os
from stagehand import Stagehand, browserbase
def with_retries(generate, attempts: int = 3):
async def wrapped(params):
for attempt in range(1, attempts + 1):
try:
return await generate(params)
except Exception:
if attempt == attempts:
raise
await asyncio.sleep(attempt)
return wrapped
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
model=with_retries(generate_with_openai),
)
```
```go theme={null}
func withRetries(generate stagehand.LLMGenerateFunc, attempts int) stagehand.LLMGenerateFunc {
return func(ctx context.Context, params stagehand.LLMGenerateParams) (stagehand.LLMGenerateResult, error) {
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
result, err := generate(ctx, params)
if err == nil {
return result, nil
}
lastErr = err
if attempt == attempts {
break
}
select {
case <-ctx.Done():
return stagehand.LLMGenerateResult{}, ctx.Err()
case <-time.After(time.Duration(attempt) * time.Second):
}
}
return stagehand.LLMGenerateResult{}, lastErr
}
}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Generate: withRetries(generateWithOpenAI, 3),
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
Need result caching rather than request retries? Use the built-in [caching
feature](/v4/best-practices/caching), which skips the inference call entirely.
***
## Troubleshooting
**Error:** `An LLM was not configured during Stagehand initialization`
You omitted `model` to get automatic routing, but the browser is not a Browserbase session, so there is no Model Gateway to route through. Automatic selection has no local fallback. `Stagehand.create()` still succeeds, because Stagehand resolves the model when a call needs one, so the first `act()`, `extract()`, or `observe()` raises this instead.
**Solutions:**
* Launch with `browserbase.launch({ apiKey })` so the session has a Browserbase API key and session ID to authorize against
* Or pass a `model` with its own provider `apiKey`, which works on any browser including local ones
* Or supply a client-side LLM callback, which runs inference in your own process
**Error:** `Model inference requires a provider API key or a Browserbase session`
You pinned a `model` but gave it no `apiKey`, and the browser is not a Browserbase session. A model without a key is a Model Gateway request, and Gateway needs a Browserbase session to bill and authorize against. This is the usual result of copying a Gateway example onto a local browser.
**Solutions:**
* Add the provider `apiKey` to the model configuration to call the provider directly
* Or launch with `browserbase.launch({ apiKey })` to keep the call on Gateway
**Error:** `Browserbase Model Gateway does not support stop sequences`
`stopSequences` is not available on Gateway inference, whether Browserbase selected the model or you pinned one without a provider key.
**Solutions:**
* Pass a `model` with its own provider `apiKey` so the call goes straight to the provider
* Or drop `stopSequences` and constrain the output with an `extract()` schema instead
**Error:** `API key not found`
**Solutions:**
* Read the provider key from the environment and pass it on the model configuration; Stagehand never reads it for you
* Confirm you are reading the variable name your provider expects
* If you intended to use automatic Model Gateway routing, omit `model` entirely and pass only your Browserbase key
* To pin a Gateway model, provide its name but omit the model provider API key
| Provider | Conventional Environment Variable |
| - | - |
| Model Gateway | `BROWSERBASE_API_KEY` (no provider key needed) |
| Google | `GOOGLE_GENERATIVE_AI_API_KEY` or `GEMINI_API_KEY` |
| Anthropic | `ANTHROPIC_API_KEY` |
| OpenAI | `OPENAI_API_KEY` |
| Groq | `GROQ_API_KEY` |
| Cerebras | `CEREBRAS_API_KEY` |
| Bring your own LLM | None; your callback owns its credentials |
**Error:** `Unsupported model`
**Solutions:**
* Use the `provider/model` format: `openai/gpt-5.6-sol`. The prefix is required; bare model names are rejected
* Use one of the five supported providers: `openai`, `anthropic`, `google`, `groq`, `cerebras`
* Check the model ID against the lists on this page. Stagehand validates the whole name, so a typo fails at `Stagehand.create()` rather than on the first inference
* Upgrade the SDK if the model shipped after your installed version
* For a provider outside that list, use the [bring-your-own-LLM callback](#custom-models)
A model that passes validation can still fail at request time if your model API key cannot reach it. That surfaces as the provider's own error, usually a `400`.
**Error:** `Model does not support structured outputs`
**Solutions:**
* Every Stagehand primitive requests a JSON schema response, so the model must support structured outputs
* Check the [Stagehand model evaluation page](https://www.stagehand.dev/evals) for recommended models
**Symptoms:** Automation is expensive or slow
**Solutions:**
* Switch to cost-effective models (check [evals](https://www.stagehand.dev/evals) for comparisons)
* Use a fast model for simple tasks and reach for a stronger one per call with a [model override](#per-call-model-overrides)
* Implement [caching](/v4/best-practices/caching) for repeated patterns
Python is a first-class SDK in Stagehand v4 with the same surface as TypeScript.
**Solutions:**
* Use the language selector on this page to see every sample in Python
* Pass an async callable as the model to bring your own LLM
### Need help? Contact support
Can't find a solution? Have a question? Reach out to the Browserbase support team:
Email Browserbase at [support@browserbase.com](mailto:support@browserbase.com)
***
## Next steps
Learn how to prompt LLMs for optimal results
Track token usage and inference latency per operation
Cache responses to reduce costs and improve speed
Reduce LLM spending with caching and smart model selection
# Observability
Source: https://docs.stagehand.dev/v4/configuration/observability
Track Stagehand automation with session visibility and analytics
Watch a run as it happens, replay it afterwards, and read back its token usage. This page covers session monitoring and resource usage on both Browserbase and local browsers.
## Browserbase session monitoring
On Browserbase, the API and dashboard give you live views, recordings, and session metadata.
### Live session visibility
Browserbase provides real-time visibility into your automation sessions:
**Session dashboard features**
* Real-time browser screen recording and replay
* Network request monitoring with detailed timing
* JavaScript console logs and error tracking
* CPU and memory usage metrics
* Session status and duration tracking
**Session management & API access**
```typescript theme={null}
import { Browserbase } from "@browserbasehq/sdk";
import { browserbase as stagehandBrowserbase, Stagehand } from "@browserbasehq/stagehand";
const bb = new Browserbase({
apiKey: process.env.BROWSERBASE_API_KEY,
});
// Create the session yourself so you hold the session ID
const session = await bb.sessions.create({
projectId: process.env.BROWSERBASE_PROJECT_ID,
});
const browser = await stagehandBrowserbase.connect({
apiKey: process.env.BROWSERBASE_API_KEY,
sessionId: session.id,
});
const stagehand = await Stagehand.create({ browser });
const sessionInfo = await bb.sessions.retrieve(session.id);
console.log("Session status:", sessionInfo.status);
console.log("Session region:", sessionInfo.region);
console.log("CPU usage:", sessionInfo.avgCpuUsage);
console.log("Memory usage:", sessionInfo.memoryUsage);
console.log("Proxy bytes:", sessionInfo.proxyBytes);
```
```python theme={null}
import os
from browserbase import Browserbase
from stagehand import Stagehand
from stagehand import browserbase as stagehand_browserbase
bb = Browserbase(api_key=os.environ["BROWSERBASE_API_KEY"])
# Create the session yourself so you hold the session ID
session = bb.sessions.create(
project_id=os.environ["BROWSERBASE_PROJECT_ID"],
)
browser = await stagehand_browserbase.connect(
api_key=os.environ["BROWSERBASE_API_KEY"],
session_id=session.id,
)
stagehand = await Stagehand.create(browser=browser)
session_info = bb.sessions.retrieve(session.id)
print("Session status:", session_info.status)
print("Session region:", session_info.region)
print("CPU usage:", session_info.avg_cpu_usage)
print("Memory usage:", session_info.memory_usage)
print("Proxy bytes:", session_info.proxy_bytes)
```
```go theme={null}
// Create the session with your Browserbase client of choice so you hold the
// session ID, then attach Stagehand to it.
session, err := createBrowserbaseSession(ctx)
if err != nil {
return err
}
browser, err := stagehand.ConnectBrowserbase(ctx, stagehand.BrowserbaseConnectOptions{
APIKey: os.Getenv("BROWSERBASE_API_KEY"),
SessionID: session.ID,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
info, err := retrieveBrowserbaseSession(ctx, session.ID)
if err != nil {
return err
}
fmt.Println("Session status:", info.Status)
fmt.Println("Session region:", info.Region)
fmt.Println("CPU usage:", info.AvgCPUUsage)
fmt.Println("Memory usage:", info.MemoryUsage)
fmt.Println("Proxy bytes:", info.ProxyBytes)
```
### Session analytics & insights
Monitor live session status, resource usage, and geographic distribution. Scale and manage concurrent sessions with real-time insights.
Review complete session recordings with frame-by-frame playback. Analyze network requests and debug browser interactions visually.
Programmatically access session data, automate lifecycle management, and integrate with monitoring systems through the Browserbase API.
Track resource consumption, session duration, and API usage. Get detailed breakdowns of costs and utilization across your automation.
### Session monitoring & filtering
Query and monitor sessions by status and metadata:
```typescript theme={null}
import { Browserbase } from "@browserbasehq/sdk";
const browserbase = new Browserbase({
apiKey: process.env.BROWSERBASE_API_KEY,
});
// The fields this example reads off a Browserbase session. Resource counters
// are optional because older API versions omit them.
type BrowserbaseSession = {
id: string;
status: string;
startedAt: string;
endedAt?: string;
region: string;
proxyBytes: number;
avgCpuUsage?: number;
memoryUsage?: number;
userMetadata?: Record;
};
// List sessions with filtering
async function getFilteredSessions() {
const sessions = await browserbase.sessions.list({
status: "RUNNING",
});
return sessions.map((session: BrowserbaseSession) => ({
id: session.id,
status: session.status, // RUNNING, COMPLETED, ERROR, TIMED_OUT
startedAt: session.startedAt,
endedAt: session.endedAt,
region: session.region,
avgCpuUsage: session.avgCpuUsage,
memoryUsage: session.memoryUsage,
proxyBytes: session.proxyBytes,
userMetadata: session.userMetadata,
}));
}
// Query sessions by metadata
async function querySessionsByMetadata(query: string) {
const sessions = await browserbase.sessions.list({
q: query,
});
return sessions;
}
```
```python theme={null}
import os
from browserbase import Browserbase
browserbase = Browserbase(api_key=os.environ["BROWSERBASE_API_KEY"])
# List sessions with filtering
def get_filtered_sessions():
sessions = browserbase.sessions.list(status="RUNNING")
return [
{
"id": session.id,
"status": session.status, # RUNNING, COMPLETED, ERROR, TIMED_OUT
"started_at": session.started_at,
"ended_at": session.ended_at,
"region": session.region,
"avg_cpu_usage": session.avg_cpu_usage,
"memory_usage": session.memory_usage,
"proxy_bytes": session.proxy_bytes,
"user_metadata": session.user_metadata,
}
for session in sessions
]
# Query sessions by metadata
def query_sessions_by_metadata(query: str):
return browserbase.sessions.list(q=query)
```
```go theme={null}
type sessionSummary struct {
ID string
Status string // RUNNING, COMPLETED, ERROR, TIMED_OUT
StartedAt time.Time
EndedAt time.Time
Region string
AvgCPUUsage float64
MemoryUsage float64
ProxyBytes int64
UserMetadata map[string]any
}
// List sessions with filtering
func getFilteredSessions(ctx context.Context) ([]sessionSummary, error) {
sessions, err := listBrowserbaseSessions(ctx, listOptions{Status: "RUNNING"})
if err != nil {
return nil, err
}
summaries := make([]sessionSummary, 0, len(sessions))
for _, session := range sessions {
summaries = append(summaries, sessionSummary{
ID: session.ID,
Status: session.Status,
StartedAt: session.StartedAt,
EndedAt: session.EndedAt,
Region: session.Region,
AvgCPUUsage: session.AvgCPUUsage,
MemoryUsage: session.MemoryUsage,
ProxyBytes: session.ProxyBytes,
UserMetadata: session.UserMetadata,
})
}
return summaries, nil
}
// Query sessions by metadata
func querySessionsByMetadata(ctx context.Context, query string) ([]browserbaseSession, error) {
return listBrowserbaseSessions(ctx, listOptions{Query: query})
}
```
Tag every session with `userMetadata` on `browserbase.launch()` so you can slice these queries by workflow, customer, or deployment.
## Local environment monitoring
For local development, Stagehand provides performance monitoring and resource tracking capabilities directly on your machine.
### Performance tracking
```typescript theme={null}
import { localBrowser, Stagehand } from "@browserbasehq/stagehand";
import { z } from "zod/v4";
const DataSchema = z.object({ title: z.string() });
const browser = await localBrowser.launch();
const stagehand = await Stagehand.create({
browser,
logging: { level: "info" }, // Monitor performance without debug noise
});
// Track local automation metrics
const startTime = Date.now();
const initialMetrics = await stagehand.metrics();
// ... perform automation tasks
const [page] = await browser.context.pages();
await page.goto("https://example.com");
await stagehand.act("click button");
await stagehand.extract("get data", DataSchema);
const finalMetrics = await stagehand.metrics();
const executionTime = Date.now() - startTime;
// Metrics are cumulative for the session, so subtract the baseline
const tokensUsed =
finalMetrics.totalPromptTokens + finalMetrics.totalCompletionTokens -
(initialMetrics.totalPromptTokens + initialMetrics.totalCompletionTokens);
console.log("Local Performance Summary:", {
executionTime: `${executionTime}ms`,
tokensUsed,
totalInferenceTime: `${finalMetrics.totalInferenceTimeMs}ms`,
tokensPerSecond: (tokensUsed / (executionTime / 1000)).toFixed(2),
});
```
```python theme={null}
from time import perf_counter
from pydantic import BaseModel
from stagehand import Stagehand, local_browser
class Data(BaseModel):
title: str
browser = await local_browser.launch()
stagehand = await Stagehand.create(
browser=browser,
logging={"level": "info"}, # Monitor without debug noise
)
# Track local automation metrics
start_time = perf_counter()
initial_metrics = await stagehand.metrics()
# ... perform automation tasks
page = (await browser.context.pages())[0]
await page.goto("https://example.com")
await stagehand.act("click button")
await stagehand.extract("get data", Data)
final_metrics = await stagehand.metrics()
execution_time = (perf_counter() - start_time) * 1000
# Metrics are cumulative for the session, so subtract the baseline
tokens_used = (
final_metrics.total_prompt_tokens
+ final_metrics.total_completion_tokens
- initial_metrics.total_prompt_tokens
- initial_metrics.total_completion_tokens
)
print("Local Performance Summary:", {
"execution_time": f"{execution_time:.0f}ms",
"tokens_used": tokens_used,
"total_inference_time": f"{final_metrics.total_inference_time_ms}ms",
"tokens_per_second": f"{tokens_used / (execution_time / 1000):.2f}",
})
```
```go theme={null}
type data struct {
Title string `json:"title"`
}
browser, err := stagehand.LaunchLocalBrowser(ctx, &stagehand.LocalBrowserLaunchOptions{})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Logging: &stagehand.StagehandClientLoggingConfig{
Level: stagehand.StagehandClientLogLevelInfo, // Monitor without debug noise
},
})
if err != nil {
return err
}
// Track local automation metrics
startTime := time.Now()
initialMetrics, err := client.Metrics(ctx)
if err != nil {
return err
}
// ... perform automation tasks
browserContext, err := browser.Context()
if err != nil {
return err
}
pages, err := browserContext.Pages(ctx)
if err != nil {
return err
}
if len(pages) == 0 {
return errors.New("Stagehand initialized without an active page")
}
page := pages[0]
if _, err := page.Goto(ctx, "https://example.com", nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("click button"), nil); err != nil {
return err
}
if _, err := stagehand.Extract[data](ctx, client, "get data", nil); err != nil {
return err
}
finalMetrics, err := client.Metrics(ctx)
if err != nil {
return err
}
executionTime := time.Since(startTime)
// Metrics are cumulative for the session, so subtract the baseline
tokensUsed := (finalMetrics.TotalPromptTokens + finalMetrics.TotalCompletionTokens) -
(initialMetrics.TotalPromptTokens + initialMetrics.TotalCompletionTokens)
fmt.Printf(
"Local Performance Summary: executionTime=%dms tokensUsed=%.0f totalInferenceTime=%.0fms tokensPerSecond=%.2f\n",
executionTime.Milliseconds(),
tokensUsed,
finalMetrics.TotalInferenceTimeMs,
tokensUsed/executionTime.Seconds(),
)
```
## Resource usage monitoring
When running locally, monitor system resource usage and browser performance:
```typescript theme={null}
import * as os from "node:os";
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
class LocalResourceMonitor {
private cpuUsage: number[] = [];
private memoryUsage: number[] = [];
startMonitoring() {
const interval = setInterval(() => {
// Track system resources
const memUsage = process.memoryUsage();
this.memoryUsage.push(memUsage.heapUsed / 1024 / 1024); // MB
// Track CPU (simplified)
const loadAvg = os.loadavg()[0];
this.cpuUsage.push(loadAvg);
}, 1000);
return interval;
}
getResourceSummary() {
return {
avgMemoryUsage: this.memoryUsage.reduce((a, b) => a + b, 0) / this.memoryUsage.length,
peakMemoryUsage: Math.max(...this.memoryUsage),
avgCpuLoad: this.cpuUsage.reduce((a, b) => a + b, 0) / this.cpuUsage.length,
totalDataPoints: this.cpuUsage.length,
};
}
}
const monitor = new LocalResourceMonitor();
const interval = monitor.startMonitoring();
const browser = await localBrowser.launch();
const stagehand = await Stagehand.create({ browser });
// ... run automation
clearInterval(interval);
console.log("Resource Usage:", monitor.getResourceSummary());
```
```python theme={null}
import asyncio
import os
import resource
from stagehand import Stagehand, local_browser
class LocalResourceMonitor:
def __init__(self) -> None:
self.cpu_usage: list[float] = []
self.memory_usage: list[float] = []
self._task: asyncio.Task | None = None
def start_monitoring(self) -> asyncio.Task:
async def sample() -> None:
while True:
# Track system resources
peak_kb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
self.memory_usage.append(peak_kb / 1024) # MB
# Track CPU (simplified)
self.cpu_usage.append(os.getloadavg()[0])
await asyncio.sleep(1)
self._task = asyncio.create_task(sample())
return self._task
def get_resource_summary(self) -> dict[str, float]:
return {
"avg_memory_usage": sum(self.memory_usage) / len(self.memory_usage),
"peak_memory_usage": max(self.memory_usage),
"avg_cpu_load": sum(self.cpu_usage) / len(self.cpu_usage),
"total_data_points": len(self.cpu_usage),
}
monitor = LocalResourceMonitor()
task = monitor.start_monitoring()
browser = await local_browser.launch()
stagehand = await Stagehand.create(browser=browser)
# ... run automation
task.cancel()
print("Resource Usage:", monitor.get_resource_summary())
```
```go theme={null}
type localResourceMonitor struct {
mu sync.Mutex
cpuUsage []float64
memoryUsage []float64
}
func (m *localResourceMonitor) startMonitoring(ctx context.Context) {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
m.mu.Lock()
// Track system resources
m.memoryUsage = append(m.memoryUsage, float64(stats.HeapAlloc)/1024/1024) // MB
// Track CPU (simplified)
m.cpuUsage = append(m.cpuUsage, float64(runtime.NumGoroutine()))
m.mu.Unlock()
}
}
}()
}
func (m *localResourceMonitor) getResourceSummary() map[string]float64 {
m.mu.Lock()
defer m.mu.Unlock()
return map[string]float64{
"avgMemoryUsage": average(m.memoryUsage),
"peakMemoryUsage": maxOf(m.memoryUsage),
"avgCpuLoad": average(m.cpuUsage),
"totalDataPoints": float64(len(m.cpuUsage)),
}
}
monitorCtx, stopMonitor := context.WithCancel(ctx)
monitor := &localResourceMonitor{}
monitor.startMonitoring(monitorCtx)
browser, err := stagehand.LaunchLocalBrowser(ctx, &stagehand.LocalBrowserLaunchOptions{})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{Browser: browser})
// ... run automation
stopMonitor()
fmt.Println("Resource Usage:", monitor.getResourceSummary())
```
Monitor token usage, costs, and speed. Set up automated alerting for critical failures. Implement cost tracking across different environments. Use session analytics to optimize automation workflows.
## Real-time metrics & monitoring
### Basic usage tracking
Monitor your automation's resource usage in real-time by calling the metrics method:
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
import { z } from "zod/v4";
const UserSchema = z.object({ name: z.string(), email: z.string() });
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
});
const stagehand = await Stagehand.create({ browser });
// Metrics are fetched from the runtime, so the call is awaited
const metrics = await stagehand.metrics();
console.log(metrics);
// Monitor during automation
const startTime = Date.now();
const initialMetrics = await stagehand.metrics();
// ... perform automation tasks
const [page] = await browser.context.pages();
await page.goto("https://example.com");
await stagehand.act("click the login button");
const { data } = await stagehand.extract("extract user info", UserSchema);
console.log(`Extracted ${data.name} <${data.email}>`);
const finalMetrics = await stagehand.metrics();
const executionTime = Date.now() - startTime;
// Metrics are cumulative for the session, so subtract the baseline
const tokensUsed =
finalMetrics.totalPromptTokens + finalMetrics.totalCompletionTokens -
(initialMetrics.totalPromptTokens + initialMetrics.totalCompletionTokens);
console.log("Automation Summary:", {
tokensUsed,
executionTime: `${executionTime}ms`,
avgInferenceTime: `${finalMetrics.totalInferenceTimeMs / 3}ms`,
});
```
```python theme={null}
import os
from time import perf_counter
from pydantic import BaseModel
from stagehand import Stagehand, browserbase
class User(BaseModel):
name: str
email: str
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
)
stagehand = await Stagehand.create(browser=browser)
# Metrics are fetched from the runtime, so the call is awaited
metrics = await stagehand.metrics()
print(metrics)
# Monitor during automation
start_time = perf_counter()
initial_metrics = await stagehand.metrics()
# ... perform automation tasks
page = (await browser.context.pages())[0]
await page.goto("https://example.com")
await stagehand.act("click the login button")
data = (await stagehand.extract("extract user info", User)).data
print(f"Extracted {data.name} <{data.email}>")
final_metrics = await stagehand.metrics()
execution_time = (perf_counter() - start_time) * 1000
# Metrics are cumulative for the session, so subtract the baseline
tokens_used = (
final_metrics.total_prompt_tokens
+ final_metrics.total_completion_tokens
- initial_metrics.total_prompt_tokens
- initial_metrics.total_completion_tokens
)
print("Automation Summary:", {
"tokens_used": tokens_used,
"execution_time": f"{execution_time:.0f}ms",
"avg_inference_time": f"{final_metrics.total_inference_time_ms / 3}ms",
})
```
```go theme={null}
type user struct {
Name string `json:"name"`
Email string `json:"email"`
}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{APIKey: apiKey})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
})
if err != nil {
return err
}
// Metrics are fetched from the runtime, so the call takes a context
metrics, err := client.Metrics(ctx)
if err != nil {
return err
}
fmt.Printf("%+v\n", metrics)
// Monitor during automation
startTime := time.Now()
initialMetrics, err := client.Metrics(ctx)
if err != nil {
return err
}
// ... perform automation tasks
browserContext, err := browser.Context()
if err != nil {
return err
}
pages, err := browserContext.Pages(ctx)
if err != nil {
return err
}
if len(pages) == 0 {
return errors.New("Stagehand initialized without an active page")
}
page := pages[0]
if _, err := page.Goto(ctx, "https://example.com", nil); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("click the login button"), nil); err != nil {
return err
}
extracted, err := stagehand.Extract[user](ctx, client, "extract user info", nil)
if err != nil {
return err
}
fmt.Printf("Extracted %s <%s>\n", extracted.Data.Name, extracted.Data.Email)
finalMetrics, err := client.Metrics(ctx)
if err != nil {
return err
}
executionTime := time.Since(startTime)
// Metrics are cumulative for the session, so subtract the baseline
tokensUsed := (finalMetrics.TotalPromptTokens + finalMetrics.TotalCompletionTokens) -
(initialMetrics.TotalPromptTokens + initialMetrics.TotalCompletionTokens)
fmt.Printf(
"Automation Summary: tokensUsed=%.0f executionTime=%dms avgInferenceTime=%.0fms\n",
tokensUsed,
executionTime.Milliseconds(),
finalMetrics.TotalInferenceTimeMs/3,
)
```
### Understanding metrics data
The metrics object provides a detailed breakdown by Stagehand operation:
```typescript theme={null}
interface StagehandMetrics {
// Act operation metrics
actPromptTokens: number;
actCompletionTokens: number;
actReasoningTokens: number;
actCachedInputTokens: number;
actInferenceTimeMs: number;
// Extract operation metrics
extractPromptTokens: number;
extractCompletionTokens: number;
extractReasoningTokens: number;
extractCachedInputTokens: number;
extractInferenceTimeMs: number;
// Observe operation metrics
observePromptTokens: number;
observeCompletionTokens: number;
observeReasoningTokens: number;
observeCachedInputTokens: number;
observeInferenceTimeMs: number;
// Cumulative totals
totalPromptTokens: number;
totalCompletionTokens: number;
totalReasoningTokens: number;
totalCachedInputTokens: number;
totalInferenceTimeMs: number;
}
```
```python theme={null}
class StagehandMetrics(BaseModel):
# Act operation metrics
act_prompt_tokens: float
act_completion_tokens: float
act_reasoning_tokens: float
act_cached_input_tokens: float
act_inference_time_ms: float
# Extract operation metrics
extract_prompt_tokens: float
extract_completion_tokens: float
extract_reasoning_tokens: float
extract_cached_input_tokens: float
extract_inference_time_ms: float
# Observe operation metrics
observe_prompt_tokens: float
observe_completion_tokens: float
observe_reasoning_tokens: float
observe_cached_input_tokens: float
observe_inference_time_ms: float
# Cumulative totals
total_prompt_tokens: float
total_completion_tokens: float
total_reasoning_tokens: float
total_cached_input_tokens: float
total_inference_time_ms: float
```
```go theme={null}
type StagehandMetrics struct {
// Act operation metrics
ActPromptTokens float64
ActCompletionTokens float64
ActReasoningTokens float64
ActCachedInputTokens float64
ActInferenceTimeMs float64
// Extract operation metrics
ExtractPromptTokens float64
ExtractCompletionTokens float64
ExtractReasoningTokens float64
ExtractCachedInputTokens float64
ExtractInferenceTimeMs float64
// Observe operation metrics
ObservePromptTokens float64
ObserveCompletionTokens float64
ObserveReasoningTokens float64
ObserveCachedInputTokens float64
ObserveInferenceTimeMs float64
// Cumulative totals
TotalPromptTokens float64
TotalCompletionTokens float64
TotalReasoningTokens float64
TotalCachedInputTokens float64
TotalInferenceTimeMs float64
}
```
**Example metrics output:**
```typescript theme={null}
const metrics = await stagehand.metrics();
console.log(metrics);
// {
// actPromptTokens: 4011,
// actCompletionTokens: 51,
// actReasoningTokens: 12,
// actCachedInputTokens: 0,
// actInferenceTimeMs: 1688,
// extractPromptTokens: 4200,
// extractCompletionTokens: 243,
// extractReasoningTokens: 18,
// extractCachedInputTokens: 0,
// extractInferenceTimeMs: 4297,
// observePromptTokens: 347,
// observeCompletionTokens: 43,
// observeReasoningTokens: 5,
// observeCachedInputTokens: 0,
// observeInferenceTimeMs: 903,
// totalPromptTokens: 8558,
// totalCompletionTokens: 337,
// totalReasoningTokens: 35,
// totalCachedInputTokens: 0,
// totalInferenceTimeMs: 6888
// }
```
```python theme={null}
metrics = await stagehand.metrics()
print(metrics)
# StagehandMetrics(
# act_prompt_tokens=4011,
# act_completion_tokens=51,
# act_reasoning_tokens=12,
# act_cached_input_tokens=0,
# act_inference_time_ms=1688,
# extract_prompt_tokens=4200,
# extract_completion_tokens=243,
# extract_reasoning_tokens=18,
# extract_cached_input_tokens=0,
# extract_inference_time_ms=4297,
# observe_prompt_tokens=347,
# observe_completion_tokens=43,
# observe_reasoning_tokens=5,
# observe_cached_input_tokens=0,
# observe_inference_time_ms=903,
# total_prompt_tokens=8558,
# total_completion_tokens=337,
# total_reasoning_tokens=35,
# total_cached_input_tokens=0,
# total_inference_time_ms=6888,
# )
```
```go theme={null}
metrics, err := client.Metrics(ctx)
if err != nil {
return err
}
fmt.Printf("%+v\n", metrics)
// stagehand.StagehandMetrics{
// ActPromptTokens: 4011,
// ActCompletionTokens: 51,
// ActReasoningTokens: 12,
// ActCachedInputTokens: 0,
// ActInferenceTimeMs: 1688,
// ExtractPromptTokens: 4200,
// ExtractCompletionTokens: 243,
// ExtractReasoningTokens: 18,
// ExtractCachedInputTokens: 0,
// ExtractInferenceTimeMs: 4297,
// ObservePromptTokens: 347,
// ObserveCompletionTokens: 43,
// ObserveReasoningTokens: 5,
// ObserveCachedInputTokens: 0,
// ObserveInferenceTimeMs: 903,
// TotalPromptTokens: 8558,
// TotalCompletionTokens: 337,
// TotalReasoningTokens: 35,
// TotalCachedInputTokens: 0,
// TotalInferenceTimeMs: 6888,
// }
```
Cached input tokens are reported separately so you can see how much of your prompt spend was served from your provider's prompt cache. This is distinct from Stagehand's own [server-side result cache](/v4/best-practices/caching), which avoids the inference call entirely.
### Operation history
Metrics give you the totals. When you want the sequence of what happened, build the timeline yourself from the log callback: every act, observe, and extract emits records with their structured data already attached. There is no history accessor on the `Stagehand` instance.
```typescript theme={null}
// Record each log entry with the time you receive it
const history: Array<{
level: "debug" | "info" | "warn" | "error";
message: string;
data: Record;
timestamp: string;
}> = [];
const stagehand = await Stagehand.create({
browser: await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }),
logging: {
level: "debug",
format: "json",
onLog(log) {
history.push({ ...log, timestamp: new Date().toISOString() });
},
},
});
// ... run your automation
// Inspect what happened, or write it to disk alongside the metrics
console.log(history.filter((entry) => entry.level === "error"));
console.log(await stagehand.metrics());
```
```python theme={null}
import os
from datetime import datetime, timezone
from stagehand import Stagehand, StagehandClientLoggingConfig, browserbase
history: list[dict] = []
def record(log) -> None:
entry = log.model_dump(mode="json")
entry["timestamp"] = datetime.now(timezone.utc).isoformat()
history.append(entry)
browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"])
stagehand = await Stagehand.create(
browser=browser,
logging=StagehandClientLoggingConfig(level="debug", format="json", on_log=record),
)
# ... run your automation
# Inspect what happened, or write it to disk alongside the metrics
print([entry for entry in history if entry["level"] == "error"])
print(await stagehand.metrics())
```
```go theme={null}
var history []stagehand.StagehandLog
var mu sync.Mutex
apiKey := os.Getenv("BROWSERBASE_API_KEY")
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: apiKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Logging: &stagehand.StagehandClientLoggingConfig{
Level: stagehand.StagehandClientLogLevelDebug,
Format: stagehand.StagehandClientLogFormatJSON,
OnLog: func(entry stagehand.StagehandLog) {
mu.Lock()
history = append(history, entry)
mu.Unlock()
},
},
})
if err != nil {
return err
}
// ... run your automation
// Inspect what happened, or write it to disk alongside the metrics.
// Copy under the mutex: the callback runs on the notification goroutine, so
// ranging over `history` directly would race with an incoming log.
mu.Lock()
captured := make([]stagehand.StagehandLog, len(history))
copy(captured, history)
mu.Unlock()
for _, entry := range captured {
if entry.Level == stagehand.StagehandLogLevelError {
fmt.Printf("%+v\n", entry)
}
}
metrics, err := client.Metrics(ctx)
if err != nil {
return err
}
fmt.Printf("%+v\n", metrics)
```
The records you capture depend on the level. Raise it to `debug` for the fullest timeline; the default is `info` and above.
On Browserbase, the session replay dashboard gives you the same timeline visually, including the network and console activity that never reaches a log callback.
### Tracing
Stagehand emits OpenTelemetry spans for every operation and for every log record, and propagates W3C trace context across the SDK and runtime boundary. Point it at your own OTLP collector to see full traces alongside the rest of your system.
```typescript theme={null}
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
});
const stagehand = await Stagehand.create({
browser,
telemetry: {
traces: {
endpoint: "https://otlp.example.com/v1/traces",
headers: { authorization: `Bearer ${process.env.OTLP_TOKEN}` },
},
},
});
```
```python theme={null}
import os
from stagehand import Stagehand, TelemetryConfig, browserbase
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
)
stagehand = await Stagehand.create(
browser=browser,
telemetry=TelemetryConfig.model_validate({
"traces": {
"endpoint": "https://otlp.example.com/v1/traces",
"headers": {"authorization": f"Bearer {os.environ['OTLP_TOKEN']}"},
}
}),
)
```
```go theme={null}
apiKey := os.Getenv("BROWSERBASE_API_KEY")
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{APIKey: apiKey})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Telemetry: stagehand.TelemetryConfig{
Traces: stagehand.TelemetryTraces{
Endpoint: "https://otlp.example.com/v1/traces",
Headers: stagehand.TelemetryTracesHeaders{
"authorization": "Bearer " + os.Getenv("OTLP_TOKEN"),
},
},
},
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
| Field | Description |
| - | - |
| endpoint | OTLP HTTP traces endpoint. Must end in `/v1/traces`. |
| headers | Headers sent with every export, typically an authorization token. |
Spans are tagged with a span type: `operation` spans wrap act, observe, and extract, and record exceptions and error status; `log` spans carry the structured data from each log record. Traces are sampled at 100%.
## Best practices
* Track session success rates and failure patterns
* Monitor resource usage and scaling requirements
* Set up automated alerting for critical failures
* Implement cost tracking across different environments
* Use session analytics to optimize automation workflows
* Compare Browserbase vs local execution times
* Monitor token usage and inference costs across models
* Track geographic performance differences
* Identify bottlenecks in automation workflows
* Optimize for cost-effectiveness and speed
* Track session distribution across regions
* Monitor concurrent session limits and scaling
* Analyze failure patterns and common error scenarios
* Use session recordings for root cause analysis
* Implement custom metadata for workflow categorization
* Integrate session APIs with monitoring dashboards
* Set up automated notifications for session failures
* Track SLA compliance and performance benchmarks
* Monitor resource costs and usage patterns
* Use analytics data for capacity planning and optimization
## Next steps
Cut token spend and latency by serving repeated act, observe, and extract calls from the server-side cache.
Configure logging levels, custom loggers, and file-based session logging.
# AI rules
Source: https://docs.stagehand.dev/v4/first-steps/ai-rules
Give your AI coding assistant the rules it needs to write correct Stagehand v4 code.
You're likely using AI to write code, and there's a **right and wrong way to do it.** This page collects the rules, configs, and copy-paste snippets that get your coding assistant writing correct Stagehand v4 code.
## Quickstart
Configure Context7, DeepWiki, and Stagehand Docs in your MCP client.
Drop in `cursorrules` and `claude.md` so AI agents/assistants always emit Stagehand patterns.
## Using MCP servers
MCP (Model Context Protocol) servers act as intermediaries that connect AI systems to external data sources and tools. These servers enable your coding assistant to access real-time information, execute tasks, and retrieve structured data to enhance code generation accuracy.
The following **MCP servers** provide specialized access to Stagehand documentation and related resources:
Provides semantic search across documentation and codebase context. Context7 enables AI assistants to find relevant code patterns, examples, and implementation details from your project history. It maintains contextual understanding of your development workflow and can surface related solutions from previous work.
**Installation:**
```json theme={null}
{
"mcpServers": {
"context7": {
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"]
}
}
}
```
Indexes GitHub repositories and their documentation. DeepWiki gives coding assistants access to repository structure, API references, and code relationships across the Stagehand repository.
**Installation:**
```json theme={null}
{
"mcpServers": {
"deepwiki": {
"url": "https://mcp.deepwiki.com/mcp"
}
}
}
```
Direct access to official Stagehand documentation. This MCP server provides AI assistants with up-to-date API references, configuration options, and usage examples for accurate code generation. Mintlify auto-generates this server from the official docs, ensuring your AI assistant always has the latest information.
**Usage:**
```json theme={null}
{
"mcpServers": {
"stagehand-docs": {
"url": "https://docs.stagehand.dev/mcp"
}
}
}
```
**How MCP servers enhance your development:**
* **Real-time Documentation Access:** AI assistants can query the latest Stagehand docs, examples, and best practices
* **Context-Aware Code Generation:** Servers provide relevant code patterns and configurations based on your specific use case
* **Reduced Integration Overhead:** Standardized protocol eliminates the need for custom integrations with each documentation source
* **Enhanced Accuracy:** AI agents receive structured, up-to-date information rather than relying on potentially outdated training data
**Prompting tip:**
Explicitly ask your coding agent/assistant to use these MCP servers to fetch relevant information from the docs so they have better context and know how to write proper Stagehand code.
ie. **"Use the stagehand-docs MCP to fetch the act/observe guidelines, then generate code that follows them. Prefer cached observe results."**
## Editor rule files (copy-paste)
Drop these in `.cursorrules`, `windsurfrules`, `claude.md`, or any agent rule framework:
````md theme={null}
# Stagehand Project
This is a project that uses Stagehand v4, a browser automation framework with AI-powered `act`, `extract`, and `observe` methods.
The main class can be imported as `Stagehand` from `@browserbasehq/stagehand`.
**Key Classes:**
- `Stagehand`: Main orchestrator class providing `act`, `extract`, and `observe` methods
- `browser.context`: A `BrowserContext` object that manages pages, cookies, and the clipboard
- `page`: Individual page objects accessed via `browser.context.activePage()`, `browser.context.pages()`, or created with `browser.context.newPage()`
There is no `agent` API in v4. Compose `observe`, `act`, and `extract` in your own control flow instead.
## Initialize
```typescript
import { browserbase, localBrowser, Stagehand } from "@browserbasehq/stagehand";
const browser = await localBrowser.launch({ headless: true });
const stagehand = await Stagehand.create({
browser,
model: {
modelName: "openai/gpt-5.6-luna",
apiKey: process.env.OPENAI_API_KEY,
},
logging: { level: "info", format: "pretty" },
});
// Access the browser context and pages
const [page] = await browser.context.pages();
const context = browser.context;
// Create new pages if needed
const page2 = await browser.context.newPage();
```
For Browserbase cloud browsers, pass the Browserbase API key to `browserbase.launch()`:
```typescript
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
});
const stagehand = await Stagehand.create({
browser,
model: { modelName: "openai/gpt-5.6-luna", apiKey: process.env.OPENAI_API_KEY },
});
```
Stagehand never reads environment variables for you. Always pass keys explicitly.
## Act
Actions are called on the `stagehand` instance (not the page). `act` takes either a string instruction or an `Action` from `observe`. Use atomic, specific instructions:
```typescript
// Act on the current active page
await stagehand.act("click the sign in button");
// Act on a specific page (when you need to target a page that isn't currently active)
await stagehand.act("click the sign in button", { page: page2 });
```
**Important:** Act instructions should be atomic and specific:
- Good: "Click the sign in button" or "Type 'hello' into the search input"
- Bad: "Order me pizza" or "Type in the search bar and hit enter" (multi-step)
Use `variables` for secrets. Values are substituted locally and never sent to the model:
```typescript
await stagehand.act("type %password% into the password field", {
variables: { password: process.env.USER_PASSWORD },
});
```
### Observe Then Act Pattern (Recommended)
`act` accepts either a string instruction or an `Action` returned by `observe`. Use `observe` to inspect the candidate action, then pass it back to `act` for deterministic replay with no inference:
```typescript
const { data: actions } = await stagehand.observe("Click the sign in button");
const [action] = actions;
if (action?.method === "click") {
await stagehand.act(action);
}
```
To target a specific page:
```typescript
const { data: actions } = await stagehand.observe("select blue as the favorite color", {
page: page2,
});
const [action] = actions;
if (action) {
await stagehand.act(action, { page: page2 });
}
```
## Extract
Extract data from pages using natural language instructions. The `extract` method is called on the `stagehand` instance and always takes both an instruction and a schema.
Every primitive returns `{ data, metadata }`. Your extracted value is on `data`; `metadata` carries the action ID and server-side cache status.
### Basic Extraction (with schema)
```typescript
import { z } from "zod/v4";
const { data } = await stagehand.extract(
"extract all apartment listings with prices and addresses",
z.object({
listings: z.array(
z.object({
price: z.string(),
address: z.string(),
}),
),
}),
);
console.log(data.listings);
```
### Simple Extraction
A schema is always required, so wrap single values in an object:
```typescript
const { data } = await stagehand.extract(
"extract the sign in button text",
z.object({ buttonText: z.string() }),
);
console.log(data.buttonText); // "Sign in"
```
### Targeted Extraction
Scope extraction to a specific element with `locator`, and prune noise with `ignoreLocators`:
```typescript
const { data } = await stagehand.extract(
"extract the reason why script injection fails",
z.object({ reason: z.string() }),
{
locator: page.locator("#main-content"),
ignoreLocators: [page.locator("nav"), page.locator(".cookie-banner")],
},
);
```
### URL Extraction
When extracting links or URLs, use `z.url()`:
```typescript
const { data } = await stagehand.extract(
"extract all navigation links",
z.object({
links: z.array(z.url()),
}),
);
```
### Extracting from a Specific Page
```typescript
const { data } = await stagehand.extract(
"extract the placeholder text on the name field",
z.object({ placeholder: z.string() }),
{ page: page2 },
);
```
### Inspecting Metadata
```typescript
const TitleSchema = z.object({ title: z.string() });
const result = await stagehand.extract("extract the page title", TitleSchema);
console.log(result.data.title);
console.log(result.metadata.actionId); // Action ID for tracing this call
console.log(result.metadata.cache.status); // "HIT", "MISS", or "DISABLED"
```
## Observe
Plan actions before executing them. Candidate actions are returned on `data`:
```typescript
// Get candidate actions on the current active page
const { data: actions } = await stagehand.observe("Click the sign in button");
const [action] = actions;
if (action) {
console.log(action.selector, action.method, action.arguments);
}
```
Observing on a specific page:
```typescript
const { data: actions } = await stagehand.observe("find the next page button", {
page: page2,
});
await stagehand.act(actions[0], { page: page2 });
```
## Advanced Features
### Locators
Use `page.locator(selector)` for deterministic, non-AI interactions. Selectors returned by `observe` are XPath strings prefixed with `xpath=`:
```typescript
await page.locator("xpath=/html/body/div[2]/button").click();
await page.locator("#email").fill("user@example.com");
const count = await page.locator("li.result").count();
```
### Multi-Page Workflows
```typescript
const page1 = await browser.context.newPage("https://example.com");
const page2 = await browser.context.newPage("https://example2.com");
// Act/extract/observe operate on the current active page by default
// Pass { page } option to target a specific page
await stagehand.act("click button", { page: page1 });
await stagehand.extract("get title", z.object({ title: z.string() }), { page: page2 });
```
### Caching
Server-side caching requires a Browserbase browser and a Browserbase API key:
```typescript
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
});
const stagehand = await Stagehand.create({
browser,
cache: true, // or { threshold: 1 }
});
```
## Cleanup
Close Stagehand before closing its browser:
```typescript
try {
const stagehand = await Stagehand.create({ browser });
try {
// ...
} finally {
await stagehand.close();
}
} finally {
await browser.close();
}
```
## Project Structure Best Practices
- Read configuration from environment variables and pass it explicitly to the `Stagehand` constructor
- Create the browser first, pass it to `Stagehand.create()`, then pass the instance into your automation functions
- Use `async`/`await` consistently; `create`, `act`, `extract`, `observe`, and `close` all return promises
- Keep Zod schemas next to the code that consumes them and reuse `z.infer` for the decoded type
- Wrap every workflow in `try`/`finally` so `close` runs even when a step throws
- Prefer narrow, atomic instructions over one instruction that describes a whole workflow
## Security Notes
- Never hard-code API keys. Read them from `process.env` and pass them explicitly; Stagehand reads no environment variables for you
- Pass secrets through `variables` so they are substituted locally and never sent to the model provider
- Set `logging: { level: "off" }` when handling sensitive data so nothing sensitive reaches your logs
- Avoid broad instructions that may trigger unintended navigation; call `observe` first, then replay the returned `Action`
## Resources/References
- TypeScript SDK: `@browserbasehq/stagehand` on npm
- Stagehand documentation: https://docs.stagehand.dev
- Stagehand Docs MCP (Mintlify): https://docs.stagehand.dev/mcp
- Context7 MCP (Upstash): https://github.com/upstash/context7
- DeepWiki MCP: https://mcp.deepwiki.com/
````
````md theme={null}
# Stagehand Python Project
This is a project that uses Stagehand v4 for Python, which provides AI-powered browser automation with `act`, `extract`, and `observe` methods.
The main class can be imported as `Stagehand` from `stagehand`.
**Key Classes:**
- `Stagehand`: Main orchestrator class providing `act`, `extract`, and `observe` methods
- `browser.context`: A `BrowserContext` object that manages pages, cookies, and the clipboard
- `page`: Individual page objects accessed via `browser.context.active_page()`, `browser.context.pages()`, or created with `browser.context.new_page()`
There is no `agent` API in v4. Compose `observe`, `act`, and `extract` in your own control flow instead.
All Stagehand methods are async. `act`, `observe`, and `extract` take `instruction` (and, for
`extract`, `schema`) positionally; every other argument is keyword-only.
## Initialize
```python
import os
from stagehand import Stagehand, browserbase, local_browser
browser = await local_browser.launch(headless=True)
stagehand = await Stagehand.create(
browser=browser,
model="openai/gpt-5.6-luna",
model_api_key=os.environ["OPENAI_API_KEY"],
logging={"level": "info", "format": "pretty"},
)
# Access the browser context and pages
page = (await browser.context.pages())[0]
context = browser.context
# Create new pages if needed
page2 = await browser.context.new_page()
```
For Browserbase cloud browsers, pass a Browserbase API key:
```python
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
)
stagehand = await Stagehand.create(browser=browser)
```
With no model configured, Browserbase Model Gateway selects one automatically for each inference call.
Stagehand never reads environment variables for you. Always pass keys explicitly.
## Act
Actions are called on the `stagehand` instance (not the page). `act` takes either a string instruction or an `Action` from `observe`. Use atomic, specific instructions:
```python
# Act on the current active page
await stagehand.act("click the sign in button")
# Act on a specific page (when you need to target a page that isn't currently active)
await stagehand.act("click the sign in button", page=page2)
```
**Important:** Act instructions should be atomic and specific:
- Good: "Click the sign in button" or "Type 'hello' into the search input"
- Bad: "Order me pizza" or "Type in the search bar and hit enter" (multi-step)
Use `variables` for secrets. Values are substituted locally and never sent to the model:
```python
await stagehand.act(
"type %password% into the password field",
variables={"password": os.environ["USER_PASSWORD"]},
)
```
### Observe Then Act Pattern (Recommended)
`act` accepts either a string instruction or an `Action` returned by `observe`. Use `observe` to inspect the candidate action, then pass it back to `act` for deterministic replay with no inference:
```python
result = await stagehand.observe("Click the sign in button")
action = result.data[0] if result.data else None
if action is not None and action.method == "click":
await stagehand.act(action)
```
To target a specific page:
```python
result = await stagehand.observe(
"select blue as the favorite color",
page=page2,
)
await stagehand.act(result.data[0], page=page2)
```
## Extract
Extract data from pages using natural language instructions. The `extract` method is called on the `stagehand` instance and always takes both an instruction and a Pydantic model.
Every primitive returns a result with `data` and `metadata`. Your extracted model instance is on `data`; `metadata` carries the action ID and server-side cache status.
### Basic Extraction (with schema)
```python
from pydantic import BaseModel
class Listing(BaseModel):
price: str
address: str
class Listings(BaseModel):
listings: list[Listing]
result = await stagehand.extract(
"extract all apartment listings with prices and addresses",
Listings,
)
print(result.data.listings)
```
### Simple Extraction
A schema is always required, so wrap single values in a model:
```python
class ButtonText(BaseModel):
button_text: str
result = await stagehand.extract(
"extract the sign in button text",
ButtonText,
)
print(result.data.button_text) # "Sign in"
```
### Targeted Extraction
Scope extraction to a specific element with `locator`, and prune noise with `ignore_locators`:
```python
class Reason(BaseModel):
reason: str
result = await stagehand.extract(
"extract the reason why script injection fails",
Reason,
locator=page.locator("#main-content"),
ignore_locators=[page.locator("nav"), page.locator(".cookie-banner")],
)
```
### URL Extraction
When extracting links or URLs, type the field as a URL:
```python
from pydantic import AnyUrl, BaseModel
class Links(BaseModel):
links: list[AnyUrl]
result = await stagehand.extract(
"extract all navigation links",
Links,
)
```
### Extracting from a Specific Page
```python
class Placeholder(BaseModel):
placeholder: str
result = await stagehand.extract(
"extract the placeholder text on the name field",
Placeholder,
page=page2,
)
```
### Inspecting Metadata
```python
class Title(BaseModel):
title: str
result = await stagehand.extract(
"extract the page title",
Title,
)
print(result.data.title)
print(result.metadata.action_id) # Action ID for tracing this call
print(result.metadata.cache.status) # "HIT", "MISS", or "DISABLED"
```
## Observe
Plan actions before executing them. Candidate actions are returned on `data`:
```python
result = await stagehand.observe("Click the sign in button")
action = result.data[0] if result.data else None
if action is not None:
print(action.selector, action.method, action.arguments)
```
Observing on a specific page:
```python
result = await stagehand.observe(
"find the next page button",
page=page2,
)
await stagehand.act(result.data[0], page=page2)
```
## Advanced Features
### Locators
Use `page.locator(selector)` for deterministic, non-AI interactions. Selectors returned by `observe` are XPath strings prefixed with `xpath=`:
```python
await page.locator("xpath=/html/body/div[2]/button").click()
await page.locator("#email").fill("user@example.com")
count = await page.locator("li.result").count()
```
### Multi-Page Workflows
```python
page1 = await browser.context.new_page("https://example.com")
page2 = await browser.context.new_page("https://example2.com")
# Act/extract/observe operate on the current active page by default
# Pass page= to target a specific page
await stagehand.act("click button", page=page1)
await stagehand.extract("get title", Title, page=page2)
```
### Caching
Server-side caching requires a Browserbase browser and a Browserbase API key:
```python
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
)
stagehand = await Stagehand.create(
browser=browser,
cache=True, # or CacheOptions(threshold=1)
)
```
## Cleanup
Close Stagehand before closing its browser:
```python
browser = await local_browser.launch(headless=True)
try:
stagehand = await Stagehand.create(browser=browser)
try:
...
finally:
await stagehand.close()
finally:
await browser.close()
```
## Project Structure Best Practices
- Store configurations in environment variables or config files
- Use async/await patterns consistently
- Implement main automation logic in async functions
- Use async context managers for resource management
- Use type hints and Pydantic models for data validation
- Handle exceptions appropriately with try/except blocks
## Security Notes
- Never hard-code API keys. Read them from `os.environ` and pass them explicitly; Stagehand reads no environment variables for you
- Pass secrets through `variables` so they are substituted locally and never sent to the model provider
- Set `logging={"level": "off"}` when handling sensitive data so nothing sensitive reaches your logs
- Avoid broad instructions that may trigger unintended navigation; call `observe` first, then replay the returned `Action`
## Resources/References
- Python SDK: `stagehand` on PyPI
- Stagehand documentation: https://docs.stagehand.dev
- Stagehand Docs MCP (Mintlify): https://docs.stagehand.dev/mcp
- Context7 MCP (Upstash): https://github.com/upstash/context7
- DeepWiki MCP: https://mcp.deepwiki.com/
````
````md theme={null}
# Stagehand Go Project
This is a project that uses Stagehand v4 for Go, which provides AI-powered browser automation with `Act`, `Extract`, and `Observe` methods.
Import the package as `stagehand "github.com/browserbase/stagehand/packages/sdk-go/v4"`.
**Key Types:**
- `*stagehand.Stagehand`: Main client, constructed with `stagehand.Create`, providing `Act`, `Extract`, and `Observe`
- `*stagehand.BrowserContext`: Returned by `browser.Context()`, manages pages, cookies, and the clipboard
- `*stagehand.Page`: Individual pages from `browserContext.ActivePage(ctx)`, `browserContext.Pages(ctx)`, or `browserContext.NewPage(ctx, url)`
There is no agent API in v4. Compose `Observe`, `Act`, and `Extract` in your own control flow instead.
Every method takes a `context.Context` as its first argument and returns an `error` as its last. Optional struct fields are pointers, so read values into variables and take their address.
## Initialize
```go
apiKey := os.Getenv("OPENAI_API_KEY")
model := stagehand.ModelConfig{
ModelName: "openai/gpt-5.6-sol",
APIKey: &apiKey,
}
browser, err := stagehand.LaunchLocalBrowser(ctx, &stagehand.LocalBrowserLaunchOptions{Headless: true})
if err != nil {
return err
}
defer func() { err = errors.Join(err, browser.Close(ctx)) }()
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Model: &model,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
// Access the browser context and pages
browserContext, err := browser.Context()
if err != nil {
return err
}
pages, err := browserContext.Pages(ctx)
if err != nil {
return err
}
if len(pages) == 0 {
return errors.New("Stagehand initialized without an active page")
}
page := pages[0]
// Create new pages if needed
_, err = browserContext.NewPage(ctx, "https://example.com")
if err != nil {
return err
}
// Target a specific page by passing it in the options
if _, err := client.Act(ctx, stagehand.ActInstruction("click the sign in button"), &stagehand.StagehandClientActOptions{
Page: page,
}); err != nil {
return err
}
```
For Browserbase cloud browsers, pass the Browserbase API key to `stagehand.LaunchBrowserbase()`:
```go
browserbaseAPIKey := os.Getenv("BROWSERBASE_API_KEY")
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: browserbaseAPIKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Model: &model,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
Stagehand never reads environment variables for you. Always pass keys explicitly.
## Act
Actions are called on the client (not the page). Use atomic, specific instructions:
```go
// Act on the current active page
if _, err := client.Act(ctx, stagehand.ActInstruction("click the sign in button"), nil); err != nil {
return err
}
// Act on a specific page (when you need to target a page that isn't currently active)
if _, err := client.Act(ctx, stagehand.ActInstruction("click the sign in button"), &stagehand.StagehandClientActOptions{
Page: page2,
}); err != nil {
return err
}
```
`Act` takes either a `string` instruction or a `stagehand.Action`. It returns an `ActResult`: the action payload is on `result.Data`, and cache status is on `result.Metadata`:
```go
result, err := client.Act(ctx, stagehand.ActInstruction("click the sign in button"), nil)
if err != nil {
return err
}
if !result.Data.Success {
return fmt.Errorf("act failed: %s", result.Data.Message)
}
```
**Important:** Act instructions should be atomic and specific:
- Good: "Click the sign in button" or "Type 'hello' into the search input"
- Bad: "Order me pizza" or "Type in the search bar and hit enter" (multi-step)
Use variables for secrets. Values are substituted locally and never sent to the model:
```go
password := os.Getenv("USER_PASSWORD")
if _, err := client.Act(ctx, stagehand.ActInstruction("type %password% into the password field"), &stagehand.StagehandClientActOptions{
Variables: stagehand.Variables{
"password": stagehand.PrimitiveVariable(stagehand.StringVariable(password)),
},
}); err != nil {
return err
}
```
### Observe Then Act Pattern (Recommended)
`Act` accepts either a string instruction or an `Action` returned by `Observe`. Use `Observe` to inspect the candidate action, then pass it back to `Act` for deterministic replay with no inference:
```go
instruction := "Click the sign in button"
observed, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
if len(observed.Data) > 0 && observed.Data[0].Method != nil && *observed.Data[0].Method == "click" {
if _, err := client.Act(ctx, stagehand.ObservedAction(observed.Data[0]), nil); err != nil {
return err
}
}
```
## Extract
Extract data from pages using natural language instructions. The generic `stagehand.Extract` function derives JSON Schema from the selected Go type and returns typed data plus metadata.
### Basic Extraction
```go
type listing struct {
Price string `json:"price"`
Address string `json:"address"`
}
type listings struct {
Listings []listing `json:"listings"`
}
data, err := stagehand.Extract[listings](
ctx,
client,
"extract all apartment listings with prices and addresses",
nil,
)
if err != nil {
return err
}
fmt.Println(data.Data.Listings)
```
### Typed Data and Metadata
`Extract` decodes `Data` into the selected Go type and preserves the protocol metadata alongside it:
```go
type buttonText struct {
ButtonText string `json:"buttonText"`
}
result, err := stagehand.Extract[buttonText](ctx, client, "extract the sign in button text", nil)
if err != nil {
return err
}
fmt.Println(result.Data.ButtonText)
fmt.Println(result.Metadata.Cache.Status)
```
### Targeted Extraction
Scope extraction to a specific element with `Locator`, and prune noise with `IgnoreLocators`:
```go
type article struct {
Body string `json:"body"`
}
result, err := stagehand.Extract[article](ctx, client, "extract the article body", &stagehand.StagehandClientExtractOptions{
Page: page,
Locator: page.Locator("#main-content"),
IgnoreLocators: []*stagehand.PageLocator{
page.Locator("nav"),
page.Locator(".cookie-banner"),
},
})
if err != nil {
return err
}
fmt.Println(result.Data.Body)
```
### URL Extraction
When extracting links or URLs, add JSON Schema constraints with `jsonschema` tags:
```go
type link struct {
Text string `json:"text"`
URL string `json:"url" jsonschema:"format=uri"`
}
type links struct {
Links []link `json:"links"`
}
```
### Extracting from a Specific Page
```go
type placeholder struct {
Placeholder string `json:"placeholder"`
}
result, err := stagehand.Extract[placeholder](ctx, client, "extract the placeholder text on the name field", &stagehand.StagehandClientExtractOptions{
Page: page2,
})
if err != nil {
return err
}
fmt.Println(result.Data.Placeholder)
```
### Inspecting Metadata
`ActionID` is a pointer, so check for nil before dereferencing. `Cache` is always present:
```go
type title struct {
Title string `json:"title"`
}
result, err := stagehand.Extract[title](ctx, client, "extract the page title", nil)
if err != nil {
return err
}
if result.Metadata.ActionID != nil {
fmt.Println("action:", *result.Metadata.ActionID) // Action ID for tracing this call
}
fmt.Println("cache:", result.Metadata.Cache.Status) // HIT, MISS, or DISABLED
```
## Observe
Plan actions before executing them. `Observe` takes an optional instruction pointer and returns candidate actions on `result.Data`:
```go
instruction := "Click the sign in button"
observed, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
for _, action := range observed.Data {
fmt.Println(action.Selector, action.Description)
}
```
Observing on a specific page:
```go
instruction := "find the next page button"
observed, err := client.Observe(ctx, &instruction, &stagehand.StagehandClientObserveOptions{
Page: page2,
})
if err != nil {
return err
}
if len(observed.Data) > 0 {
if _, err := client.Act(ctx, stagehand.ObservedAction(observed.Data[0]), &stagehand.StagehandClientActOptions{Page: page2}); err != nil {
return err
}
}
```
## Advanced Features
### Locators
Use `page.Locator(selector)` for deterministic, non-AI interactions. Selectors returned by `Observe` are XPath strings prefixed with `xpath=`:
```go
if err := page.Locator("xpath=/html/body/div[2]/button").Click(ctx, nil); err != nil {
return err
}
if err := page.Locator("#email").Fill(ctx, "user@example.com"); err != nil {
return err
}
count, err := page.Locator("li.result").Count(ctx)
if err != nil {
return err
}
fmt.Println("results:", count)
```
### Multi-Page Workflows
```go
page1, err := browserContext.NewPage(ctx, "https://example.com")
if err != nil {
return err
}
page2, err := browserContext.NewPage(ctx, "https://example2.com")
if err != nil {
return err
}
// Act/Extract/Observe operate on the current active page by default
// Pass Page in the options struct to target a specific page
if _, err := client.Act(ctx, stagehand.ActInstruction("click button"), &stagehand.StagehandClientActOptions{Page: page1}); err != nil {
return err
}
if _, err := client.Act(ctx, stagehand.ActInstruction("click another button"), &stagehand.StagehandClientActOptions{Page: page2}); err != nil {
return err
}
```
### Caching
Server-side caching requires a Browserbase browser and a Browserbase API key:
```go
cache := stagehand.CacheEnabled(true) // or stagehand.CacheWithThreshold(1)
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: browserbaseAPIKey,
})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Cache: &cache,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
```
## Cleanup
Always close the client. The idiomatic pattern is a named error return plus a deferred `errors.Join`:
```go
func run(ctx context.Context) (err error) {
browser, err := stagehand.LaunchLocalBrowser(ctx, &stagehand.LocalBrowserLaunchOptions{Headless: true})
if err != nil {
return err
}
defer func() { err = errors.Join(err, browser.Close(ctx)) }()
client, err := stagehand.Create(ctx, stagehand.CreateOptions{Browser: browser})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
// ...
return nil
}
```
## Project Structure Best Practices
- Read configuration from environment variables and pass it explicitly
- Thread `context.Context` through every call so cancellation works
- Check every returned `error`; do not discard them
- Define extraction shapes once as Go types; `stagehand.Extract` derives JSON Schema from them
- Use `jsonschema` struct tags for constraints such as formats, descriptions, and bounds
## Security Notes
- Never hard-code API keys. Read them with `os.Getenv` and pass their addresses explicitly; Stagehand reads no environment variables for you
- Pass secrets through `ActOptions.Variables` so they are substituted locally and never sent to the model provider
- The Go client has no log level filter. Leave `Logging.OnLog` unset, or redact inside the handler, when handling sensitive data
- Avoid broad instructions that may trigger unintended navigation; call `Observe` first, then replay the returned `Action`
## Resources/References
- Go SDK: `github.com/browserbase/stagehand/packages/sdk-go/v4`
- Stagehand documentation: https://docs.stagehand.dev
- Stagehand Docs MCP (Mintlify): https://docs.stagehand.dev/mcp
- Context7 MCP (Upstash): https://github.com/upstash/context7
- DeepWiki MCP: https://mcp.deepwiki.com/
````
## Security notes
* Do not embed secrets in docs or rule files; use environment variables in MCP configs.
* Pass secrets through `variables` so they are substituted locally and never sent to the model provider.
* Set `logging.level` to `"off"` when handling sensitive data so nothing sensitive reaches your logs.
* Avoid broad actions that may trigger unintended navigation; prefer `observe` first.
## Resources/references
* Context7 MCP (Upstash)
* [https://github.com/upstash/context7](https://github.com/upstash/context7)
* DeepWiki MCP
* [https://mcp.deepwiki.com/](https://mcp.deepwiki.com/)
* Stagehand Docs MCP (Mintlify)
* [https://docs.stagehand.dev/mcp](https://docs.stagehand.dev/mcp)
# Installation
Source: https://docs.stagehand.dev/v4/first-steps/installation
Add Stagehand to an existing project.
Install Stagehand in your current app.
Node.js is the recommended runtime environment for Stagehand scripts. Stagehand requires Node.js 22.18 or later, Python 3.11 or later, or Go 1.26 or later.
**Bun is supported.** Stagehand drives the browser over the Chrome DevTools Protocol and has no Playwright dependency, so there is no runtime caveat.
### Install dependencies
```bash theme={null}
pnpm add @browserbasehq/stagehand 'zod@~4.4.3'
# npm install @browserbasehq/stagehand 'zod@~4.4.3'
# yarn add @browserbasehq/stagehand 'zod@~4.4.3'
# bun add @browserbasehq/stagehand 'zod@~4.4.3'
```
```bash theme={null}
pip install stagehand
# uv add stagehand
# poetry add stagehand
```
```bash theme={null}
go get github.com/browserbase/stagehand/packages/sdk-go/v4@v4.0.0
```
If you plan to run locally, you need to have [Chrome](https://www.google.com/chrome/) installed on your machine. For cloud browser sessions, skip this.
### Configure environment
Set your Browserbase API key. When no model is configured, Model Gateway selects one automatically, so no model provider key is required:
```bash theme={null}
export BROWSERBASE_API_KEY=your_api_key
```
Stagehand does not read environment variables on your behalf, and it does not auto-load env files.
This also applies to API routing: pass non-production Browserbase and Stagehand API URLs explicitly rather than setting `BROWSERBASE_BASE_URL` or `STAGEHAND_API_URL`. See [API endpoint overrides](/v4/configuration/browser#api-endpoint-overrides).
Read the values in your own app code and pass them explicitly to the browser factory:
```typescript theme={null}
// Optional: install dotenv first (pnpm add dotenv), then load an env file yourself
import "dotenv/config";
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
});
const stagehand = await Stagehand.create({ browser });
```
```python theme={null}
import os
from stagehand import Stagehand, browserbase
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
)
stagehand = await Stagehand.create(browser=browser)
```
```go theme={null}
// Every optional field is a pointer, so read your key into a variable first
apiKey := os.Getenv("BROWSERBASE_API_KEY")
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{APIKey: apiKey})
if err != nil {
return err
}
client, err := stagehand.Create(ctx, stagehand.CreateOptions{Browser: browser})
```
### Use in your codebase
Add Stagehand where you need browser automation.
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
import { z } from "zod/v4";
async function main() {
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
});
try {
const stagehand = await Stagehand.create({ browser });
try {
const [page] = await browser.context.pages();
await page.goto("https://example.com");
// Act on the page
await stagehand.act("Click the learn more button");
// Extract structured data
const { data } = await stagehand.extract(
"extract the description",
z.object({ description: z.string() }),
);
console.log(data.description);
} finally {
await stagehand.close();
}
} finally {
await browser.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```python theme={null}
import asyncio
import os
from pydantic import BaseModel
from stagehand import Stagehand, browserbase
class Description(BaseModel):
description: str
async def main() -> None:
browser = await browserbase.launch(
api_key=os.environ["BROWSERBASE_API_KEY"],
)
try:
stagehand = await Stagehand.create(browser=browser)
try:
page = (await browser.context.pages())[0]
await page.goto("https://example.com")
# Act on the page
await stagehand.act("Click the learn more button")
# Extract structured data
result = await stagehand.extract(
"extract the description",
Description,
)
print(result.data.description)
finally:
await stagehand.close()
finally:
await browser.close()
asyncio.run(main())
```
```go theme={null}
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
stagehand "github.com/browserbase/stagehand/packages/sdk-go/v4"
)
type description struct {
Description string `json:"description"`
}
func main() {
if err := run(context.Background()); err != nil {
log.Fatal(err)
}
}
func run(ctx context.Context) (err error) {
apiKey := os.Getenv("BROWSERBASE_API_KEY")
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{APIKey: apiKey})
if err != nil {
return err
}
defer func() { err = errors.Join(err, browser.Close(ctx)) }()
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
browserContext, err := browser.Context()
if err != nil {
return err
}
pages, err := browserContext.Pages(ctx)
if err != nil {
return err
}
if len(pages) == 0 {
return errors.New("Stagehand initialized without an active page")
}
page := pages[0]
if _, err := page.Goto(ctx, "https://example.com", nil); err != nil {
return err
}
// Act on the page
if _, err := client.Act(ctx, stagehand.ActInstruction("Click the learn more button"), nil); err != nil {
return err
}
// Extract structured data
extracted, err := stagehand.Extract[description](
ctx,
client,
"extract the description",
nil,
)
if err != nil {
return err
}
fmt.Println(extracted.Data.Description)
return nil
}
```
## Next steps
Browser sources, Browserbase vs local, logging, timeouts, LLM customization
Perform precise actions with natural language
Typed data extraction with schemas
Discover elements and suggested actions
# Introducing Stagehand
Source: https://docs.stagehand.dev/v4/first-steps/introduction
Developers use Stagehand to reliably automate the web.
Stagehand is built for agents. Playwright was built for testing. Use familiar APIs, self-healing actions, and network-level security across TypeScript, Python, and Go.
## The problem with browser automation
Traditional frameworks like Playwright and Puppeteer force you to write brittle scripts that break with every UI change. Web agents promise to solve this with AI, but leave you at the mercy of unpredictable behavior.
**You're stuck between two bad options:**
* **Too brittle:** Traditional selectors break when websites change
* **Too agentic:** AI agents are unpredictable and impossible to debug
## Enter Stagehand
Stagehand provides access to multiple layers of control:
* **AI primitives** (`act`, `extract`, `observe`) for self-healing natural-language steps
* **Playwright-style APIs** on [`page`](/v4/reference/page) (`goto`, `click`, `type`, `locator`, `screenshot`) for deterministic browser control
You can mix both in the same script and decide how much AI each step uses.
### AI primitives
Execute actions using natural language
Pull structured data with schemas
Discover available actions on any page
```typescript theme={null}
// Act: execute natural language actions
await stagehand.act("click the login button");
// Extract: pull structured data
const { data } = await stagehand.extract(
"extract the price",
z.object({ price: z.number() }),
);
// Observe: discover available actions
const { data: actions } = await stagehand.observe("find submit buttons");
```
```python theme={null}
# Act: execute natural language actions
await stagehand.act("click the login button")
# Extract: pull structured data
class Price(BaseModel):
price: float
result = await stagehand.extract(
"extract the price",
Price,
)
price = result.data.price
# Observe: discover available actions
actions = (await stagehand.observe("find submit buttons")).data
```
```go theme={null}
// ctx and client come from the quickstart
// Act: execute natural language actions
actResult, err := client.Act(ctx, stagehand.ActInstruction("click the login button"), nil)
if err != nil {
return err
}
fmt.Println(actResult.Data.Success)
// Extract: pull structured data
type price struct {
Price float64 `json:"price"`
}
extracted, err := stagehand.Extract[price](ctx, client, "extract the price", nil)
if err != nil {
return err
}
fmt.Println(extracted.Data.Price)
// Observe: discover available actions
instruction := "find submit buttons"
observed, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
fmt.Println(len(observed.Data), "candidate actions")
```
### Playwright-style APIs
When you know the selector or want zero inference, use [`page`](/v4/reference/page) methods you already know.
```typescript theme={null}
const page = await stagehand.browser.context.activePage();
await page.goto("https://example.com");
await page.locator('textarea[name="q"]').fill("Browserbase");
await page.keyPress("Enter");
await page.screenshot();
```
```python theme={null}
page = await stagehand.browser.context.active_page()
await page.goto("https://example.com")
await page.locator('textarea[name="q"]').fill("Browserbase")
await page.key_press("Enter")
await page.screenshot()
```
```go theme={null}
// page comes from the quickstart (pages[0])
if _, err := page.Goto(ctx, "https://example.com", nil); err != nil {
return err
}
if err := page.Locator(`textarea[name="q"]`).Fill(ctx, "Browserbase"); err != nil {
return err
}
if err := page.KeyPress(ctx, "Enter", nil); err != nil {
return err
}
if _, err := page.Screenshot(ctx, nil); err != nil {
return err
}
```
## Why developers choose Stagehand
* **Precise control:** Mix AI-powered actions with deterministic `page` APIs. You decide exactly how much AI to use.
* **Runtime lives in the browser:** Stagehand runs next to the page, so remote browsers feel as fast as local ones.
* **First-class TypeScript, Python, and Go SDKs:** Every method and option matches across supported languages.
* **Extraction is typed:** [`extract`](/v4/basics/extract) validates results against a schema you define and hands back fully typed data.
* **Models are flexible:** Use a supported provider by name or supply your own client-side LLM callback.
* **Metrics are built in:** Read per-method token usage and inference timing with [`metrics()`](/v4/reference/stagehand).
* **Built for agent harnesses:** Stagehand is the hands. Bring your own agent as the brain (LangChain, CrewAI, Mastra, or a custom loop).
## Built for modern development
Stagehand is designed for developers building production browser automations and AI agents that need reliable web access.
Compatible with all Chromium-based browsers: Chrome, Edge, Arc, Brave, and more. Stagehand drives the browser over the Chrome DevTools Protocol, so there is no Playwright or Puppeteer dependency.
Created and maintained by the team behind enterprise browser infrastructure.
## Get started in 60 seconds
Browserbase recommends running Stagehand on [Browserbase](https://www.browserbase.com). A hosted browser is what enables [server-side caching](/v4/best-practices/caching) and the [Model Gateway](/v4/configuration/models#model-gateway).
Build your first automation in under a minute
See real-world automation examples
Get help from the community
Add Stagehand to your project
# Quickstart
Source: https://docs.stagehand.dev/v4/first-steps/quickstart
Build your first Stagehand automation with act, extract, and observe.
The quickest way to start with Stagehand is to install the SDK, point it at a browser, and write a script. This page gets you from an empty directory to a working automation in three steps, using a browser on your own machine.
```bash theme={null}
mkdir my-stagehand-app && cd my-stagehand-app
pnpm init -y
pnpm install @browserbasehq/stagehand 'zod@~4.4.3'
```
Keep Zod on the `4.4.x` minor version to match Stagehand's supported types. Newer Zod minor versions can cause TypeScript errors when passing schemas to `extract()`.
```bash theme={null}
mkdir my-stagehand-app && cd my-stagehand-app
python3 -m venv .venv && source .venv/bin/activate
pip install stagehand
```
```bash theme={null}
mkdir my-stagehand-app && cd my-stagehand-app
go mod init example.com/my-stagehand-app
go get github.com/browserbase/stagehand/packages/sdk-go/v4@v4.0.0
```
Stagehand drives a local browser through the Chrome DevTools Protocol, so install [Chrome](https://www.google.com/chrome/) on your machine before running the script.
Create the example script (`index.ts`, `main.py`, or `main.go`). It exercises all three primitives: act, extract, and observe.
```typescript theme={null}
import { localBrowser, Stagehand } from "@browserbasehq/stagehand";
import { z } from "zod/v4";
async function main() {
const browser = await localBrowser.launch();
try {
const stagehand = await Stagehand.create({
browser,
model: {
modelName: "openai/gpt-5.6-sol",
apiKey: process.env.OPENAI_API_KEY,
},
});
console.log("Stagehand session started");
try {
const [page] = await browser.context.pages();
await page.goto("https://stagehand.dev");
const extractResult = await stagehand.extract(
"Extract the value proposition from the page.",
z.object({ valueProposition: z.string() }),
);
console.log("Extract result:\n", extractResult.data);
await stagehand.act("Click the 'Evals' button.");
const observeResult = await stagehand.observe("What can I click on this page?");
console.log("Observe result:\n", observeResult.data);
} finally {
await stagehand.close();
}
} finally {
await browser.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
```python theme={null}
import asyncio
import os
from pydantic import BaseModel
from stagehand import Stagehand, local_browser
class ValueProposition(BaseModel):
value_proposition: str
async def main() -> None:
browser = await local_browser.launch()
try:
stagehand = await Stagehand.create(
browser=browser,
model="openai/gpt-5.6-sol",
model_api_key=os.environ["OPENAI_API_KEY"],
)
print("Stagehand session started")
try:
page = (await browser.context.pages())[0]
await page.goto("https://stagehand.dev")
extract_result = await stagehand.extract(
"Extract the value proposition from the page.",
ValueProposition,
)
print("Extract result:\n", extract_result.data)
await stagehand.act("Click the 'Evals' button.")
observe_result = await stagehand.observe("What can I click on this page?")
print("Observe result:\n", observe_result.data)
finally:
await stagehand.close()
finally:
await browser.close()
asyncio.run(main())
```
```go theme={null}
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
stagehand "github.com/browserbase/stagehand/packages/sdk-go/v4"
)
type valueProposition struct {
ValueProposition string `json:"value_proposition"`
}
func main() {
if err := run(context.Background()); err != nil {
log.Fatal(err)
}
}
func run(ctx context.Context) (err error) {
modelAPIKey := os.Getenv("OPENAI_API_KEY")
model := stagehand.ModelConfig{
ModelName: "openai/gpt-5.6-sol",
APIKey: &modelAPIKey,
}
browser, err := stagehand.LaunchLocalBrowser(ctx, &stagehand.LocalBrowserLaunchOptions{})
if err != nil {
return err
}
defer func() { err = errors.Join(err, browser.Close(ctx)) }()
client, err := stagehand.Create(ctx, stagehand.CreateOptions{
Browser: browser,
Model: &model,
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
fmt.Println("Stagehand session started")
browserContext, err := browser.Context()
if err != nil {
return err
}
pages, err := browserContext.Pages(ctx)
if err != nil {
return err
}
if len(pages) == 0 {
return errors.New("Stagehand initialized without an active page")
}
page := pages[0]
if _, err := page.Goto(ctx, "https://stagehand.dev", nil); err != nil {
return err
}
extracted, err := stagehand.Extract[valueProposition](
ctx,
client,
"Extract the value proposition from the page.",
nil,
)
if err != nil {
return err
}
fmt.Printf("Extract result:\n%+v\n", extracted)
if _, err := client.Act(ctx, stagehand.ActInstruction("Click the 'Evals' button."), nil); err != nil {
return err
}
instruction := "What can I click on this page?"
observeResult, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
fmt.Printf("Observe result:\n%+v\n", observeResult.Data)
return nil
}
```
Stagehand never reads environment variables on your behalf. Read your model provider API key in your own code and pass it to `Stagehand.create()`, as the script above does. A local browser cannot use the [Model Gateway](/v4/configuration/models#model-gateway), so Stagehand requires you to provide a model and its API key.
Set your model provider API key, then run the script. Stagehand launches Chrome on your machine and opens a visible window, so you can watch each step as it happens.
```bash theme={null}
export OPENAI_API_KEY="sk-..." # Your model provider API key
pnpm dlx tsx index.ts # Run the example script
```
```bash theme={null}
export OPENAI_API_KEY="sk-..." # Your model provider API key
python main.py # Run the example script
```
```bash theme={null}
export OPENAI_API_KEY="sk-..." # Your model provider API key
go run . # Run the example script
```
Ready to run in the cloud, with stealth, proxies, and session recordings? Swap `localBrowser.launch()` for `browserbase.launch()` with your Browserbase API key, and the [Model Gateway](/v4/configuration/models#model-gateway) picks and authenticates a model for you. See [Browser configuration](/v4/configuration/browser).
## Next steps
Learn about the Stagehand primitives: act, extract, and observe.
Perform actions on web pages with natural language
Get structured data with typed schemas
Discover available elements and actions
Add Stagehand to an existing project
# CrewAI
Source: https://docs.stagehand.dev/v4/integrations/agent-frameworks/crewai
Give a CrewAI agent persistent Stagehand browser tools over MCP/stdio.
The CrewAI integration connects a Python agent to the Stagehand facade MCP server over MCP/stdio. One server process owns the browser, so page state survives across `run`, `snapshot`, and `screenshot` calls.
Stagehand ships this experimental integration from the repository rather than publishing it as a standalone adapter.
## Prerequisites
* Node.js 24 or newer
* pnpm 11.10.0
* Python 3.11–3.13
* [uv](https://docs.astral.sh/uv/)
* A model-provider credential for the CrewAI agent
* A current Google Chrome installation for local browser mode
## Quickstart
```bash theme={null}
git clone https://github.com/browserbase/stagehand.git
cd stagehand
pnpm install --frozen-lockfile
pnpm exec turbo run build \
--filter @browserbasehq/stagehand-integrations
```
```bash theme={null}
cd packages/integrations/crewai
uv sync --locked
```
The example uses an OpenAI model by default. Keep the provider credential in the CrewAI process:
```bash theme={null}
export OPENAI_API_KEY="your-openai-api-key"
```
Set `CREWAI_MODEL` to use another model supported by CrewAI.
The integration uses local Chrome by default. To use Browserbase instead:
```bash theme={null}
export STAGEHAND_BROWSER="browserbase"
export BROWSERBASE_API_KEY="your-browserbase-api-key"
```
```bash theme={null}
uv run python agent.py \
"Open https://example.com and report the page title."
```
## Configuration
| Variable | Purpose |
| - | - |
| `CREWAI_MODEL` | CrewAI agent model. |
| `OPENAI_API_KEY` | Credential for the example's default agent model. CrewAI does not forward it to the MCP child. |
| `STAGEHAND_BROWSER` | Select `local` or `browserbase`. |
| `BROWSERBASE_API_KEY` | Required for Browserbase. |
| `BROWSERBASE_PROJECT_ID` | Optional Browserbase project ID. |
| `STAGEHAND_MODEL_NAME` | Optional model for Stagehand AI methods called inside `run`. |
| `STAGEHAND_MODEL_API_KEY` | Required with `STAGEHAND_MODEL_NAME`; the MCP child does not receive agent-provider credentials. |
## Screenshot behavior
CrewAI's current tool loop is text-only, and its standard MCP adapter drops image blocks. This integration saves each screenshot to a temporary file and returns the path to the agent. Open the returned path to inspect the image.
## Keep the browser session alive
The example opens one MCP session around the complete CrewAI run. Keep that lifetime when adapting the example: creating a new MCP process for each tool call loses the browser and invalidates prior snapshot IDs.
`run` executes model-authored JavaScript in the browser. Use Browserbase for untrusted tasks and review the [integration security boundary](/v4/integrations/agent-frameworks/overview#security-boundary).
Read the adapter, agent prompt, and contract tests.
# Deep Agents
Source: https://docs.stagehand.dev/v4/integrations/agent-frameworks/deep-agents
Give LangChain Deep Agents persistent Stagehand tools locally or in a managed deployment.
The Deep Agents integration provides two deployment patterns with the same `run`, `snapshot`, and `screenshot` tools.
| Example | Tool connection | Browser lifecycle |
| - | - | - |
| Local | MCP over stdio | One browser per MCP client session |
| Managed | Native Python tools | One Browserbase runtime per managed thread |
Stagehand ships this experimental integration from the repository rather than publishing it as a standalone adapter.
## Run locally
The local example requires Python 3.11–3.13, [uv](https://docs.astral.sh/uv/), a provider credential for the Deep Agent, and a current Google Chrome installation for local browser mode.
```bash theme={null}
git clone https://github.com/browserbase/stagehand.git
cd stagehand/packages/integrations/deepagents
uv sync --locked
uv sync --project examples/local --locked
```
Open `examples/local/agent.py` and edit its `model`, `instruction`, and optional Pydantic `response_format`. Then export the matching model-provider credential:
```bash theme={null}
export OPENAI_API_KEY="your-openai-api-key"
```
The MCP server launches visible local Chrome by default. To use Browserbase instead:
```bash theme={null}
export STAGEHAND_BROWSER="browserbase"
export BROWSERBASE_API_KEY="your-browserbase-api-key"
```
Set `STAGEHAND_HEADLESS=true` to run local Chrome without a visible window.
```bash theme={null}
uv run --project examples/local --locked \
python examples/local/agent.py
```
Run `agents2.py` for a structured-output form-filling example that uses mock data and does not submit the form.
```bash theme={null}
uv run --project examples/local --locked \
python examples/local/agents2.py
```
## Local server configuration
| Variable | Default | Purpose |
| - | - | - |
| `STAGEHAND_BROWSER` | `local` | Select `local` or `browserbase`. |
| `STAGEHAND_HEADLESS` | `false` | Run local Chrome headlessly. |
| `STAGEHAND_START_URL` | Unset | Open a URL when the MCP server starts. |
| `STAGEHAND_MODEL` | Unset | Optional model for Stagehand AI methods called inside `run`. |
| `STAGEHAND_MODEL_API_KEY` | Unset | Required with `STAGEHAND_MODEL`; the MCP child does not receive agent-provider credentials. |
| `STAGEHAND_API_URL` | Unset | Optional Stagehand Model Gateway URL. |
| `STAGEHAND_RUN_TIMEOUT_MS` | `60000` | Timeout for JavaScript and snapshot-action batches. |
| `BROWSERBASE_API_KEY` | Unset | Required for Browserbase. |
The server and client use separate Python environments. Stagehand and the current LangGraph SDK require incompatible `websockets` versions, so stdio keeps their dependency sets isolated.
Keep one persistent MCP `ClientSession`. Stateless tool loading starts a new stdio process for each call, which loses the browser and snapshot IDs.
## Deploy with Managed Deep Agents
The managed example uses native Python tools and Browserbase. It requires a Deep Agents model credential and a Browserbase API key.
```bash theme={null}
cd packages/integrations/deepagents/examples/managed
uv sync --locked
```
Use `examples/managed/.env.example` as the list of required names and set them through your shell or deployment secret manager. At minimum, configure:
Configure `DEEPAGENTS_MODEL` and its matching provider credential, `BROWSERBASE_API_KEY`, and either `STAGEHAND_API_URL` for Model Gateway or `STAGEHAND_MODEL` with `STAGEHAND_MODEL_API_KEY` for direct-provider BYOK.
```bash theme={null}
uv run mda dev .
uv run mda deploy .
```
Browser state remains available while the managed worker is warm. Stagehand does not yet support durable reconnection after a worker replacement.
`run` executes model-authored JavaScript in the browser. Use Browserbase for untrusted tasks and review the [integration security boundary](/v4/integrations/agent-frameworks/overview#security-boundary).
Browse the local MCP server, managed tools, and example agents.
# Eve
Source: https://docs.stagehand.dev/v4/integrations/agent-frameworks/eve
Give Eve, Vercel's framework for building agents, native Stagehand browser tools backed by one durable browser session.
The Stagehand integration binds `run`, `snapshot`, and `screenshot` as native in-process [Eve](https://vercel.com/eve) tools backed by one durable browser session, without starting an MCP server or bridge process. Eve is Vercel's framework for building agents with Markdown instructions and skills, TypeScript tools, and durable execution.
Stagehand ships this experimental integration from the repository rather than publishing it as a standalone adapter.
## Prerequisites
* Node.js 24 or newer
* pnpm 11.10.0
* A model-provider credential for Eve
* A current Google Chrome installation for local browser mode
## Quickstart
```bash theme={null}
git clone https://github.com/browserbase/stagehand.git
cd stagehand
pnpm install --frozen-lockfile
pnpm exec turbo run build \
--filter @browserbasehq/stagehand-integrations
```
The example uses an OpenAI model by default:
```bash theme={null}
export OPENAI_API_KEY="your-openai-api-key"
```
Set `EVE_STAGEHAND_MODEL` to select another Eve model.
The example uses local Chrome by default. To use Browserbase instead:
```bash theme={null}
export STAGEHAND_BROWSER="browserbase"
export BROWSERBASE_API_KEY="your-browserbase-api-key"
```
```bash theme={null}
pnpm --dir packages/integrations/eve dev
```
At Eve's `>` terminal prompt, enter:
```text theme={null}
Open https://example.com and report the page title.
```
Eve uses the `run` tool and should report `Example Domain` before returning to the prompt.
## Configuration
| Variable | Purpose |
| - | - |
| `EVE_STAGEHAND_MODEL` | Eve agent model. |
| `OPENAI_API_KEY` | Credential for the example's default Eve model. |
| `STAGEHAND_BROWSER` | Select `local` or `browserbase`. |
| `BROWSERBASE_API_KEY` | Required for Browserbase. |
| `BROWSERBASE_PROJECT_ID` | Optional Browserbase project ID. |
| `STAGEHAND_MODEL_NAME` | Optional. The facade tools are deterministic and do not call a Stagehand model today. |
| `STAGEHAND_MODEL_API_KEY` | Optional credential for `STAGEHAND_MODEL_NAME`; Eve can infer a supported provider key. |
| `STAGEHAND_EVE_SESSION_FILE` | Optional file where Eve persists a Browserbase session ID. Defaults to the system temporary directory. |
## Session lifecycle
One Eve world process owns one shared browser. Use this example for a single Eve session because concurrent sessions in the process share pages, authentication, and other browser state.
On Browserbase, the example creates a `keepAlive` session and writes its ID to a temporary file. A process restart reattaches to the stored session instead of silently creating another one. Tool errors do not reset a healthy browser.
A Browserbase keep-alive session continues running and can continue billing until Eve reattaches it, you release it through Browserbase, or the project timeout ends it.
`run` executes model-authored JavaScript in the browser. For untrusted tasks, review the [integration security boundary](/v4/integrations/agent-frameworks/overview#security-boundary).
Read the native tool bindings and durable-session implementation.
# Mastra
Source: https://docs.stagehand.dev/v4/integrations/agent-frameworks/mastra
Give a Mastra agent persistent Stagehand browser tools over MCP/stdio.
The Mastra integration connects an agent to the Stagehand facade MCP server over MCP/stdio. The MCP client remains open for the agent run, so browser state survives across tool calls.
Stagehand ships this experimental integration from the repository rather than publishing it as a standalone adapter.
## Prerequisites
* Node.js 24 or newer
* pnpm 11.10.0
* An OpenAI API key for the example agent
* A current Google Chrome installation for local browser mode
## Quickstart
```bash theme={null}
git clone https://github.com/browserbase/stagehand.git
cd stagehand
pnpm install --frozen-lockfile
pnpm exec turbo run build \
--filter @browserbasehq/stagehand-integrations
```
```bash theme={null}
export OPENAI_API_KEY="your-openai-api-key"
```
Set `MASTRA_STAGEHAND_MODEL` to select another model supported by the example.
The example uses local Chrome by default. To use Browserbase instead:
```bash theme={null}
export STAGEHAND_BROWSER="browserbase"
export BROWSERBASE_API_KEY="your-browserbase-api-key"
```
```bash theme={null}
pnpm --dir packages/integrations/mastra start -- \
"Open https://example.com and report the page title."
```
## Configuration
| Variable | Purpose |
| - | - |
| `MASTRA_STAGEHAND_MODEL` | Mastra agent model. |
| `OPENAI_API_KEY` | Credential for the example agent. Mastra does not forward it to the MCP child. |
| `STAGEHAND_BROWSER` | Select `local` or `browserbase`. |
| `BROWSERBASE_API_KEY` | Required for Browserbase. |
| `BROWSERBASE_PROJECT_ID` | Optional Browserbase project ID. |
| `STAGEHAND_MODEL_NAME` | Optional model for Stagehand AI methods called inside `run`. |
| `STAGEHAND_MODEL_API_KEY` | Required with `STAGEHAND_MODEL_NAME`; the MCP child does not receive agent-provider credentials. |
## Keep the browser session alive
The example creates one MCP client and one facade MCP server for the entire model loop. Preserve that lifetime if you adapt the integration; a new process per tool call starts a new browser.
The MCP child receives only Stagehand and Browserbase configuration plus the process values required to launch Node. The host's model credential remains in the Mastra process.
`run` executes model-authored JavaScript in the browser. Use Browserbase for untrusted tasks and review the [integration security boundary](/v4/integrations/agent-frameworks/overview#security-boundary).
Read the MCP client setup, agent prompt, and contract tests.
# Agent Frameworks
Source: https://docs.stagehand.dev/v4/integrations/agent-frameworks/overview
Add persistent browser tools to your agent framework through MCP or native bindings.
Use Stagehand as the browser layer in an agent you build with an agent framework. Your framework manages the model, instructions, and tool loop; Stagehand keeps the browser session available as the agent navigates, interacts with pages, and reads results.
Choose an integration based on your framework and how it connects tools:
| Framework | Connection | Guide |
| - | - | - |
| Eve | Native TypeScript tools in the Eve process. | [Eve](/v4/integrations/agent-frameworks/eve) |
| Deep Agents | MCP over stdio locally, or native Python tools in Managed Deep Agents. | [Deep Agents](/v4/integrations/agent-frameworks/deep-agents) |
| CrewAI | Python agent connected to the TypeScript MCP server over stdio. | [CrewAI](/v4/integrations/agent-frameworks/crewai) |
| Mastra | MCP tools in a Mastra agent. | [Mastra](/v4/integrations/agent-frameworks/mastra) |
| Vercel AI SDK | MCP tools in an AI SDK tool loop. | [Vercel AI SDK](/v4/integrations/agent-frameworks/vercel-ai-sdk) |
Each integration provides `run`, `snapshot`, and `screenshot`. To connect an existing coding agent instead, see [CLI Agents](/v4/integrations/cli-agents/overview).
These integrations are experimental and run from the Stagehand repository. The adapters and shared integration package are not published as standalone packages.
## Tool contract
The agent framework integrations expose the same browser capabilities.
Read a compact accessibility tree for the active page. Pass the bracketed IDs on interactive elements to `run` actions.
IDs are valid only for the latest snapshot of the active page. Take another snapshot after navigation or when an ID becomes stale.
Execute JavaScript against Playwright-shaped `page`, `context`, and `browser` objects, or send a batch of actions that reference snapshot IDs.
`run` accepts exactly one of `code` or `actions`. Snapshot actions support `click`, `hover`, `fill`, `type`, `press`, and `select`.
```json JavaScript theme={null}
{
"code": "await page.goto('https://example.com'); return await page.title();"
}
```
```json Snapshot actions theme={null}
{
"actions": [{ "op": "click", "id": "1-42" }]
}
```
Capture the active page as a PNG or JPEG for visual inspection. The integration adapts the image to the tool-result format supported by your framework.
## How sessions work
The tools share a browser for the lifetime of the integration's client session. A navigation performed by `run` is visible to the next `snapshot`, and authentication and page state remain available across calls.
With MCP, keep one client connection open so the server and browser stay alive across calls. With native bindings, keep the browser runtime available for the lifetime of the agent session. Deployment-specific persistence and reconnection behavior are documented in each integration guide.
Do not create a new MCP process for every tool call. Doing so starts a new browser and invalidates any snapshot IDs from the previous call.
## Set up your framework
Start with your framework's guide for its runtime requirements, installation steps, and tool registration. Integrations can connect through a stdio MCP server or bind browser tools directly in the agent process.
1. Install the integration and its required runtime dependencies.
2. Register the browser tools with your framework's agent loop.
3. Configure the browser and the agent's model credentials.
4. Keep the browser connection available across tool calls, and close it when the session ends.
## Choose a browser
Use local Chrome for development or a Browserbase session for hosted browser execution. Set the browser mode explicitly when you need predictable behavior across environments; defaults and environment-variable handling depend on the integration.
For Browserbase, provide an API key and any project configuration required by the integration. For local mode, install a supported version of Chrome. See your framework's guide for the exact settings.
## Configure models
Your framework's agent model decides which browser tool to call and what input to send. Configure that model and its credentials through your framework.
Stagehand's browser tools do not require a separate model for deterministic operations such as navigation, snapshots, and screenshots. If your integration supports Stagehand AI methods such as `act`, `extract`, or `observe`, configure a Stagehand model when using those methods.
Keep the agent model and Stagehand model configuration separate. Credential forwarding and provider-key inference vary by integration; use its guide for the supported configuration names and how to pass credentials.
## Security boundary
`run` executes model-authored JavaScript inside the Stagehand browser extension's service worker, not in the agent host process. That code can control the browser and access anything available in its session.
Use Browserbase as the isolation boundary for untrusted tasks. Treat authenticated browser sessions and any data reachable from them as privileged.
Pass only the credentials required by the browser runtime. Configure the framework’s tool permissions and credential handling according to its integration guide.
Browse the shared implementation and all framework examples on GitHub.
# Vercel AI SDK
Source: https://docs.stagehand.dev/v4/integrations/agent-frameworks/vercel-ai-sdk
Give a Vercel AI SDK agent persistent Stagehand browser tools over MCP/stdio.
The Vercel AI SDK integration connects an AI SDK tool loop to the Stagehand facade MCP server over MCP/stdio. One server process owns the browser, so page state survives across `run`, `snapshot`, and `screenshot` calls.
Building with Eve? Use the [native Eve integration](/v4/integrations/agent-frameworks/eve) to run the same tool contract in-process.
Stagehand ships this experimental integration from its repository and does not publish it as a standalone adapter.
## Prerequisites
* Node.js 24 or newer
* pnpm 11.10.0
* An OpenAI API key for the example agent
* A current Google Chrome installation for local browser mode
## Quickstart
```bash theme={null}
git clone https://github.com/browserbase/stagehand.git
cd stagehand
pnpm install --frozen-lockfile
pnpm exec turbo run build \
--filter @browserbasehq/stagehand-integrations
```
```bash theme={null}
export OPENAI_API_KEY="your-openai-api-key"
```
Set `AI_SDK_STAGEHAND_MODEL` to select another model supported by the example.
The example uses local Chrome by default. To use Browserbase instead:
```bash theme={null}
export STAGEHAND_BROWSER="browserbase"
export BROWSERBASE_API_KEY="your-browserbase-api-key"
```
```bash theme={null}
pnpm --dir packages/integrations/vercel-ai start -- \
"Open https://example.com and report the page title."
```
## Configuration
| Variable | Purpose |
| - | - |
| `AI_SDK_STAGEHAND_MODEL` | AI SDK agent model. |
| `OPENAI_API_KEY` | Credential for the example agent. The AI SDK process does not forward it to the MCP child. |
| `STAGEHAND_BROWSER` | Select `local` or `browserbase`. |
| `BROWSERBASE_API_KEY` | Required for Browserbase. |
| `BROWSERBASE_PROJECT_ID` | Optional Browserbase project ID. |
| `STAGEHAND_MODEL_NAME` | Optional model for Stagehand AI methods called inside `run`. |
| `STAGEHAND_MODEL_API_KEY` | Required with `STAGEHAND_MODEL_NAME`; the MCP child does not receive agent-provider credentials. |
## Keep the browser session alive
The example connects once with `createMCPClient`, loads the three tools, and closes the client only after the model loop finishes. Keep that lifetime when adapting the example; reconnecting for each call starts another browser and loses previous state.
The MCP child receives only Stagehand and Browserbase configuration plus the process values required to launch Node. The host's model credential remains in the AI SDK process.
`run` executes model-authored JavaScript in the browser. Use Browserbase for untrusted tasks and review the [integration security boundary](/v4/integrations/agent-frameworks/overview#security-boundary).
Read the MCP client setup, agent loop, and contract tests.
# Stripe
Source: https://docs.stagehand.dev/v4/integrations/applications/stripe
Use Stagehand and WebMCP to fill Stripe Checkout and submit payment.
The [Stripe WebMCP sample](https://github.com/stripe-samples/webmcp/pull/5) opens a Stripe Checkout page, selects a payment method, and fills the form through tools published by the page. Stagehand clicks the Pay button to submit payment.
The checkout flow is currently proposed in [stripe-samples/webmcp#5](https://github.com/stripe-samples/webmcp/pull/5). The setup below checks out the reviewed sample commit because the default branch may still contain the earlier tool-discovery example.
## Requirements
* Node.js 24 and pnpm 10.32.1.
* Google Chrome with WebMCP support. The maintainers tested the sample with Chrome 153; Stagehand enables the required launch flags.
* A Stripe Checkout URL that exposes WebMCP tools. Use a test-mode payment link and test card data to try the sample.
The sample uses the published Stagehand TypeScript SDK and launches local Chrome. It does not require the shared agent framework integration package or a model-provider API key.
## Run the sample
```bash theme={null}
git clone https://github.com/stripe-samples/webmcp.git
cd webmcp
git fetch origin pull/5/head
git checkout a4075bf887a5e87855ea2e2e9cb0b19734770e88
cd stagehand
pnpm install --frozen-lockfile
cp .env.example .env
```
Edit `.env` with your Checkout URL, buyer details, shipping address, and test card data. The sample's `.env.example` lists the individual fields.
| Variable | Purpose |
| - | - |
| `STRIPE_URL` | Checkout URL that exposes WebMCP tools. |
| `CONFIRM_PURCHASE` | Must be `yes` to run the script and submit payment. |
| `MAX_TOTAL_MINOR` | Positive spending limit in the order currency's minor units. For USD, `900` means \$9.00. |
| `BUYER_EMAIL`, `BUYER_PHONE`, `BUYER_PHONE_COUNTRY` | Contact details for the form. |
| `SHIPPING_*` | Name and address fields, also used for billing when the session exposes billing fields. |
| `CARD_NUMBER`, `CARD_EXPIRY`, `CARD_CVC` | Card details. Use the test values in `.env.example` with a test-mode Checkout URL. |
| `KEEP_OPEN` | Optional. Set to `yes` to leave Chrome open after the payment step. |
This script clicks Pay and can charge a real card on a live Checkout page. `CONFIRM_PURCHASE=yes` and `MAX_TOTAL_MINOR` do not enforce test mode.
```bash theme={null}
pnpm start
```
The script logs the order before payment, clicks Pay if the total is within the configured limit, then logs the page URL and an order summary. Confirm the payment outcome in Checkout: if the final summary cannot be retrieved, the sample prints the pre-payment summary instead.
## How it works
The sample discovers tools with [`page.tools()`](/v4/basics/webmcp) and invokes them through Stagehand's WebMCP API.
1. Poll for `select_payment_method` and select `card`.
2. Read `fill_payment_form.inputSchema` and send only fields present in that Checkout session. Shipping and billing field names vary by session.
3. Fill one field per invocation, rediscovering the tool between calls because the form can re-render.
4. Read `get_order_summary` and abort if `totalAmount` exceeds `MAX_TOTAL_MINOR`.
5. Click `.SubmitButton` with a Stagehand locator. The sample's Checkout flow exposes no Pay tool.
The sample catches errors from individual form-fill calls because a re-render can prevent an invocation from acknowledging a value that was applied. Those errors do not prove that a field was filled successfully; check the form when adapting the flow.
Read the complete script and environment configuration.
Discover, invoke, and handle results from page-provided tools.
# Claude Code
Source: https://docs.stagehand.dev/v4/integrations/cli-agents/claude-code
Give a Claude Code agent persistent Stagehand browser tools over MCP/stdio.
The Claude Code integration connects a Claude Agent SDK `query` loop to the Stagehand facade MCP server over MCP/stdio. One server process owns the browser, so page state survives across `run`, `snapshot`, and `screenshot` calls.
Stagehand ships this experimental integration from the repository rather than publishing it as a standalone adapter.
## Prerequisites
* Node.js 24 or newer
* pnpm 11.10.0
* An Anthropic API key for the example agent
* A current Google Chrome installation for local browser mode
## Quickstart
```bash theme={null}
git clone https://github.com/browserbase/stagehand.git
cd stagehand
pnpm install --frozen-lockfile
pnpm exec turbo run build \
--filter @browserbasehq/stagehand-integrations
```
```bash theme={null}
export ANTHROPIC_API_KEY="your-anthropic-api-key"
```
Set `CLAUDE_STAGEHAND_MODEL` to select another model; the example defaults to `claude-sonnet-5`.
The example defaults to Browserbase when `BROWSERBASE_API_KEY` is set, otherwise it uses local Chrome:
```bash theme={null}
export STAGEHAND_BROWSER="browserbase"
export BROWSERBASE_API_KEY="your-browserbase-api-key"
```
```bash theme={null}
pnpm --dir packages/integrations/claude-code start -- \
"Open https://example.com and report the page title."
```
## Configuration
| Variable | Purpose |
| - | - |
| `CLAUDE_STAGEHAND_MODEL` | Claude Code agent model; defaults to `claude-sonnet-5`. |
| `ANTHROPIC_API_KEY` | Credential for the example agent. The Claude Agent SDK does not forward it to the MCP child. |
| `STAGEHAND_BROWSER` | Select `local` or `browserbase`. Defaults to `browserbase` when `BROWSERBASE_API_KEY` is set, otherwise `local`. |
| `BROWSERBASE_API_KEY` | Required for Browserbase. |
| `BROWSERBASE_PROJECT_ID` | Optional Browserbase project ID. |
| `STAGEHAND_MODEL_NAME` | Optional model for Stagehand AI methods called inside `run`. |
| `STAGEHAND_MODEL_API_KEY` | Required with `STAGEHAND_MODEL_NAME`; the MCP child does not receive agent-provider credentials. |
## Keep the browser session alive
The example mounts one facade MCP server for the entire `query` loop and limits `allowedTools` to the three `mcp__stagehand__` tools. A `canUseTool` guard denies everything else, because headless runs hang on any unanswered permission prompt. Preserve that lifetime if you adapt the integration; a new process per tool call starts a new browser.
The MCP child receives only Stagehand and Browserbase configuration plus the process values required to launch Node. The host's model credential remains in the Claude Code process.
## Connect a running Claude Code CLI
The package ships a project-scoped `.mcp.json` that mounts the same facade server in the Claude Code CLI. Its `args` path is relative to the package, so start the CLI from that directory:
```bash theme={null}
cd packages/integrations/claude-code
claude
```
Claude Code inherits your shell environment, so the exports above are the only configuration. For a headless one-shot run:
```bash theme={null}
claude -p "your instruction" --mcp-config .mcp.json --allowedTools "mcp__stagehand__run,mcp__stagehand__snapshot,mcp__stagehand__screenshot"
```
`run` executes model-authored JavaScript in the browser. Use Browserbase for untrusted tasks and review the [integration security boundary](/v4/integrations/cli-agents/overview#security-boundary).
Read the MCP mount, permission guard, and contract tests.
# Codex
Source: https://docs.stagehand.dev/v4/integrations/cli-agents/codex
Give a Codex agent persistent Stagehand browser tools over MCP/stdio.
The Codex integration gives a Codex agent a persistent Stagehand browser it can drive with `run`, `snapshot`, and `screenshot`. A Codex SDK thread connects to the Stagehand facade MCP server over MCP/stdio, and one server process owns the browser, so page state survives across tool calls.
Stagehand ships this experimental integration from the repository rather than publishing it as a standalone adapter.
## Prerequisites
* Node.js 24 or newer
* pnpm 11.10.0
* An OpenAI API key or an existing `codex login` for the example agent
* A current Google Chrome installation for local browser mode
## Quickstart
```bash theme={null}
git clone https://github.com/browserbase/stagehand.git
cd stagehand
pnpm install --frozen-lockfile
pnpm exec turbo run build \
--filter @browserbasehq/stagehand-integrations
```
```bash theme={null}
export OPENAI_API_KEY="your-openai-api-key"
```
Codex picks its own harness-tuned default model. Set `CODEX_STAGEHAND_MODEL` to override it.
The example defaults to Browserbase when `BROWSERBASE_API_KEY` is set, otherwise it uses local Chrome:
```bash theme={null}
export STAGEHAND_BROWSER="browserbase"
export BROWSERBASE_API_KEY="your-browserbase-api-key"
```
```bash theme={null}
pnpm --dir packages/integrations/codex start -- \
"Open https://example.com and report the page title."
```
The example sets `approvals_reviewer = "auto_review"` in the Codex config override. Headless MCP tool calls on machines without a global `approvals_reviewer` fail with "user cancelled MCP tool call" without it, regardless of approval policy. If pnpm skips the SDK's vendored-binary postinstall, set `export CODEX_PATH_OVERRIDE=$(which codex)`.
## Configuration
| Variable | Purpose |
| - | - |
| `CODEX_STAGEHAND_MODEL` | Optional model override; by default Codex uses its own harness-tuned model. |
| `OPENAI_API_KEY` | Credential for the example agent. Codex does not forward it to the MCP child. |
| `CODEX_PATH_OVERRIDE` | Optional path to a `codex` binary when the SDK's vendored binary is missing. |
| `STAGEHAND_BROWSER` | Select `local` or `browserbase`. |
| `BROWSERBASE_API_KEY` | Required for Browserbase. |
| `BROWSERBASE_PROJECT_ID` | Optional Browserbase project ID. |
| `STAGEHAND_MODEL_NAME` | Optional model for Stagehand AI methods called inside `run`. |
| `STAGEHAND_MODEL_API_KEY` | Required with `STAGEHAND_MODEL_NAME`; the MCP child does not receive agent-provider credentials. |
## Keep the browser session alive
The example starts one Codex thread against one facade MCP server, with raised MCP startup and tool timeouts because browser launches exceed the Codex defaults. The local sandbox stays read-only; the browser work happens in the MCP server. Preserve that lifetime if you adapt the integration; a new process per tool call starts a new browser. Codex has no native turn limit; long tasks run until the model finishes.
The MCP child receives only Stagehand and Browserbase configuration plus the process values required to launch Node. The host's model credential remains in the Codex process.
## Connect a running Codex CLI
The same server works from the Codex CLI. Merge the package's `config.toml` into `~/.codex/config.toml`, adjusting the path to your checkout:
```toml theme={null}
[mcp_servers.stagehand]
command = "node"
args = ["/absolute/path/to/stagehand/packages/integrations/core/dist/facade/stdio-server.mjs"]
[mcp_servers.stagehand.env]
STAGEHAND_BROWSER = "browserbase"
BROWSERBASE_API_KEY = "bb_live_..."
BROWSERBASE_PROJECT_ID = "..."
```
Codex does not expand shell variables in config values; paste the real keys or generate this file from your environment. For a one-off run, pass the same values as `codex exec` overrides:
```bash theme={null}
codex exec \
-c mcp_servers.stagehand.command=node \
-c 'mcp_servers.stagehand.args=["/absolute/path/to/packages/integrations/core/dist/facade/stdio-server.mjs"]' \
-c 'mcp_servers.stagehand.env={ STAGEHAND_BROWSER = "browserbase", BROWSERBASE_API_KEY = "bb_live_..." }' \
"your instruction"
```
These overrides merge with any `[mcp_servers]` already in your `~/.codex/config.toml` rather than replacing them; set `CODEX_HOME` to a scratch directory if you need isolation.
`run` executes model-authored JavaScript in the browser. Use Browserbase for untrusted tasks and review the [integration security boundary](/v4/integrations/cli-agents/overview#security-boundary).
Read the Codex config override, agent thread setup, and contract tests.
# fx
Source: https://docs.stagehand.dev/v4/integrations/cli-agents/fx
Give the fx coding agent persistent Stagehand browser tools over MCP/stdio.
The fx integration connects the [fx coding agent](https://fx.sh) to the Stagehand facade MCP server over MCP/stdio. One MCP server process owns the browser, so navigation, authentication, and page state survive across tool calls.
Stagehand ships this experimental integration from the repository rather than publishing it as a standalone adapter. The example is verified against fx v0.0.3.
## Prerequisites
* Node.js 24 or newer
* pnpm 11.10.0
* fx v0.0.3
* A Vercel AI Gateway login or API key for fx
* A current Google Chrome installation for local browser mode
## Quickstart
```bash theme={null}
git clone https://github.com/browserbase/stagehand.git
cd stagehand
pnpm install --frozen-lockfile
pnpm exec turbo run build \
--filter @browserbasehq/stagehand-integrations
```
Pin the tested version because fx is experimental:
```bash theme={null}
curl -fsSL https://fx.sh/setup.sh | bash -s -- v0.0.3
fx login
```
You can export `AI_GATEWAY_API_KEY` instead of using `fx login`. See the [fx authentication guide](https://fx.sh/docs/getting-started/authentication).
fx reads MCP servers from `~/.fx/mcp.json`, not from the repository. Merge this entry into that file and replace the placeholder with the absolute path to your checkout:
```json theme={null}
{
"mcp": {
"stagehand": {
"type": "stdio",
"command": [
"node",
"/absolute/path/to/stagehand/packages/integrations/core/dist/facade/stdio-server.mjs",
"--max-screenshot-base64-bytes=60000"
],
"required": true
}
}
}
```
Start a new fx session after saving the file, or run `/mcp reload` in an open session.
The facade uses local Chrome by default. To use a disposable Browserbase browser instead:
```bash theme={null}
export STAGEHAND_BROWSER="browserbase"
export BROWSERBASE_API_KEY="your-browserbase-api-key"
```
Start fx from the integration directory so it loads the included limits, project instructions, and Stagehand skill:
```bash theme={null}
cd packages/integrations/fx
fx
```
At the prompt, enter:
```text theme={null}
Use the Stagehand browser tools to open https://example.com, take a snapshot,
and report the heading with its snapshot ID.
```
## Tool discovery
fx prefixes MCP tool names with the server name. The Stagehand tools appear as:
* `mcp_stagehand_run`
* `mcp_stagehand_snapshot`
* `mcp_stagehand_screenshot`
fx v0.0.3 may return no matches for this server from `mcp_search_tools`. The integration's `AGENTS.md` and Stagehand skill tell fx to select these exact names directly. Run fx from `packages/integrations/fx` so it loads that guidance.
There is no separate navigate or start tool. Navigate with `mcp_stagehand_run` and `page.goto`:
```json theme={null}
{
"code": "await page.goto('https://example.com'); return { url: await page.url(), title: await page.title() };"
}
```
The first browser tool call launches the browser lazily. Later calls reuse the same session.
## Keep screenshots within fx's frame limit
MCP screenshots contain inline base64 image data. fx enforces its raw response-frame limit before it can parse or truncate a tool result, so a full-page PNG can close the MCP connection.
The configured `--max-screenshot-base64-bytes=60000` mode defaults screenshots to a viewport JPEG at quality 40. If a requested image is still too large, the facade retries progressively smaller viewport JPEGs. When no image fits, it returns a small tool error and keeps the MCP session alive.
Use an explicit viewport JPEG when you do not need full-page detail:
```json theme={null}
{
"type": "jpeg",
"quality": 40,
"fullPage": false
}
```
## Permissions
Interactive fx sessions can approve tool calls when prompted. For non-interactive browser tasks, pre-allow the three Stagehand tools in `~/.fx/settings.json`:
```json theme={null}
{
"permission": {
"mcp_stagehand_run": "allow",
"mcp_stagehand_snapshot": "allow",
"mcp_stagehand_screenshot": "allow",
"run_command": "deny"
}
}
```
Denying `run_command` prevents a browser-only task from falling back to shell exploration if tool discovery fails.
## Configuration
| Variable or file | Purpose |
| - | - |
| `~/.fx/mcp.json` | User-global MCP server configuration. Repository-local MCP configuration is not loaded. |
| `packages/integrations/fx/.fx.json` | Raises fx's tool-result and agent-step limits for browser work. |
| `AI_GATEWAY_API_KEY` | Alternative to an interactive `fx login`. |
| `STAGEHAND_BROWSER` | Select `local` or `browserbase`. |
| `BROWSERBASE_API_KEY` | Required for Browserbase. |
| `BROWSERBASE_PROJECT_ID` | Optional Browserbase project ID. |
| `STAGEHAND_MODEL_NAME` | Optional model for Stagehand AI methods called inside `run`. |
| `STAGEHAND_MODEL_API_KEY` | Credential for `STAGEHAND_MODEL_NAME`. |
Leave the MCP entry's `environment` field unset to inherit exported variables. If you add an `environment` object, fx replaces the child environment rather than merging it, so include `PATH` and every variable the facade needs.
`run` executes model-authored JavaScript in the browser. Use Browserbase for untrusted tasks and review the [integration security boundary](/v4/integrations/cli-agents/overview#security-boundary).
Read the MCP config template, project limits, and Stagehand tool guidance.
# CLI Agents
Source: https://docs.stagehand.dev/v4/integrations/cli-agents/overview
Connect coding agents to a persistent Stagehand browser with the MCP adapter.
Give your coding agent a browser it can use alongside its coding tools. Stagehand's MCP adapter lets the agent navigate a running application, interact with its UI, and read page content without adding a browser tool loop to your project.
CLI agents that support stdio MCP can connect to the Stagehand MCP adapter. One server process owns the browser session, so navigation, authentication, and page state remain available across tool calls.
## Choose a CLI agent
| Agent | Connection | Setup |
| - | - | - |
| Claude Code | MCP server configured in a project `.mcp.json`. | [Claude Code](/v4/integrations/cli-agents/claude-code#connect-a-running-claude-code-cli) |
| Codex | MCP server configured in `~/.codex/config.toml`. | [Codex](/v4/integrations/cli-agents/codex#connect-a-running-codex-cli) |
| fx | MCP server configured in `~/.fx/mcp.json`. | [fx](/v4/integrations/cli-agents/fx) |
| Pi | Native extension that registers Stagehand tools directly. | [Pi](/v4/integrations/cli-agents/pi) |
Agents with a native extension integration can register the browser tools directly. Follow the individual agent guide for that setup; the steps below cover the MCP adapter.
These integrations are experimental and run from the Stagehand repository. The shared adapter is not published as a standalone package.
## Set up the MCP adapter
You need Node.js 24 or newer, pnpm 11.10.0, and an installed, authenticated CLI agent. Local browser mode also requires Google Chrome.
```bash theme={null}
git clone https://github.com/browserbase/stagehand.git
cd stagehand
pnpm install --frozen-lockfile
pnpm exec turbo run build \
--filter @browserbasehq/stagehand-integrations
```
Configure a stdio MCP server with these values, replacing the path with your checkout:
| Setting | Value |
| - | - |
| Command | `node` |
| Arguments | `["/absolute/path/to/stagehand/packages/integrations/core/dist/facade/stdio-server.mjs"]` |
Use the linked agent guide for the exact configuration format and permissions. Let the CLI agent start the server and keep the connection open for the session.
Set `STAGEHAND_BROWSER=local` in the MCP server's environment to use local Chrome. To use Browserbase, set `STAGEHAND_BROWSER=browserbase` and `BROWSERBASE_API_KEY`; `BROWSERBASE_PROJECT_ID` is optional.
When `STAGEHAND_BROWSER` is unset, the adapter selects Browserbase if `BROWSERBASE_API_KEY` is present, otherwise local Chrome. Follow the agent guide to pass environment variables to its MCP child process.
Start or reload your agent's MCP connection, then ask:
```text theme={null}
Use Stagehand to open https://example.com and report the page title.
```
The agent should use `run` to navigate and report `Example Domain`. Your CLI agent manages its own model and authentication.
## Configure models
Your CLI agent selects its own model and manages its authentication. The MCP adapter provides browser tools to that agent; it does not choose or replace the agent's model.
Deterministic browser operations do not require a separate Stagehand model. If you use Stagehand AI methods through an integration that supports them, configure the Stagehand model separately and pass its required credentials to the browser runtime. See your agent's guide for supported settings and environment forwarding.
## Browser tools
| Tool | What the agent can do |
| - | - |
| `run` | Execute JavaScript against the browser's `page`, `context`, and `browser` objects, or perform supported snapshot actions. |
| `snapshot` | Read a compact accessibility tree of the active page. |
| `screenshot` | Capture the page for visual inspection. |
For example, the agent can navigate with this `run` input:
```json theme={null}
{
"code": "await page.goto('https://example.com'); return await page.title();"
}
```
Keep one MCP connection open throughout the task. Starting a new server for each call creates a new browser and loses the previous session's page state. Snapshot element IDs belong to the latest snapshot; take another snapshot after navigation or when an ID becomes stale.
## Security boundary
`run` executes agent-authored JavaScript in the Stagehand browser extension's service worker. It can control the browser and access data available in that session.
Use Browserbase for untrusted browser tasks, and configure tool permissions in your CLI agent. Treat authenticated browser sessions as privileged. The adapter does not change the permissions of the agent's other tools.
Read the shared browser tools and stdio server implementation.
# Pi
Source: https://docs.stagehand.dev/v4/integrations/cli-agents/pi
Give the Pi coding agent native Stagehand browser tools registered directly by an extension.
The Pi integration gives a Pi agent native `run`, `snapshot`, and `screenshot` tools backed by one persistent Stagehand browser per Pi session. Pi ships without built-in MCP by design; extensions register tools directly, so this integration runs in process without an MCP server or bridge. The tool descriptions, runtime validators, and system prompt come from the shared facade contract, so the tools behave the same as the MCP-based integrations.
Stagehand ships this experimental integration from the repository rather than publishing it as a standalone adapter.
## Prerequisites
* Node.js 24 or newer
* pnpm 11.10.0
* A model-provider credential for Pi (`OPENAI_API_KEY` or `ANTHROPIC_API_KEY`)
* A current Google Chrome installation for local browser mode
## Quickstart
```bash theme={null}
git clone https://github.com/browserbase/stagehand.git
cd stagehand
pnpm install --frozen-lockfile
pnpm exec turbo run build \
--filter @browserbasehq/stagehand-integrations
```
```bash theme={null}
export OPENAI_API_KEY="your-openai-api-key"
```
Export `ANTHROPIC_API_KEY` instead to use an Anthropic model. This credential is for Pi's own model, not the browser.
The extension defaults to Browserbase when `BROWSERBASE_API_KEY` is set, otherwise it uses local Chrome:
```bash theme={null}
export STAGEHAND_BROWSER="browserbase"
export BROWSERBASE_API_KEY="your-browserbase-api-key"
```
```bash theme={null}
cd packages/integrations/pi
pnpm start "Open https://example.com and report the page title."
Two headless gotchas: print mode reads piped stdin (always redirect `
## Install permanently
To register the extension for every Pi session instead of loading it per invocation:
```bash theme={null}
pi install ./packages/integrations/pi
```
The `pi` manifest key in the package's `package.json` points at the extension.
## Configuration
| Variable | Purpose |
| - | - |
| `OPENAI_API_KEY` | Credential for Pi's own model. |
| `ANTHROPIC_API_KEY` | Alternative credential for Pi's own model. |
| `STAGEHAND_BROWSER` | Select `local` or `browserbase`. |
| `BROWSERBASE_API_KEY` | Required for Browserbase. |
| `BROWSERBASE_PROJECT_ID` | Optional Browserbase project ID. |
| `STAGEHAND_MODEL_NAME` | Optional. The facade tools are deterministic and do not call a Stagehand model today. |
| `STAGEHAND_MODEL_API_KEY` | Optional credential for `STAGEHAND_MODEL_NAME`; the extension can infer a supported provider key from its environment. |
## Session lifecycle
The extension factory does not start a browser; factories also run in invocations that never start a session, such as `pi --list-models`. The browser launches lazily on the first tool call, relaunches if it closed, and closes on session shutdown. Shutdown awaits a still-pending launch before closing so it does not leak a racing launch.
`snapshot` and `run` return text content. `screenshot` returns real image content because Pi supports multimodal tool results.
`run` executes model-authored JavaScript in the browser. Use Browserbase for untrusted tasks and review the [integration security boundary](/v4/integrations/cli-agents/overview#security-boundary).
Read the extension tool registrations and lazy browser lifecycle.
# Integrations
Source: https://docs.stagehand.dev/v4/integrations/overview
Connect Stagehand to your agents, frameworks, and application workflows.
Use Stagehand to bring browser automation into the tools you already use. Connect an agent to a browser so it can work across websites, or call Stagehand from an application to automate a specific workflow.
The guides below cover setup, configuration, and working examples. Choose an integration based on what you want to connect.
## Agent Frameworks
Build browser capabilities into your own agent using the framework of your choice. The [Agent Frameworks overview](/v4/integrations/agent-frameworks/overview) explains the shared tools and how to connect them to your framework's agent loop.
Give Vercel's framework for building durable agents native Stagehand tools.
Run locally over MCP or deploy native tools with Managed Deep Agents.
Connect a Python CrewAI agent over MCP/stdio.
Connect a Mastra agent over MCP/stdio.
Use Stagehand tools in a Vercel AI SDK tool loop over MCP/stdio.
## CLI Agents
Give a coding agent browser tools for tasks such as checking a running application or gathering information from the web. The [CLI Agents overview](/v4/integrations/cli-agents/overview) explains how to connect browser tools through the MCP adapter or native extensions.
Give a Claude Code agent persistent Stagehand browser tools over MCP/stdio.
Give a Codex agent persistent Stagehand browser tools over MCP/stdio.
Give the fx coding agent persistent Stagehand browser tools over MCP/stdio.
Give the Pi coding agent native Stagehand browser tools registered directly by an extension.
## Applications
Use Stagehand directly from your application or script to automate a service's browser workflow. Your code controls the sequence of steps and can combine browser interactions with tools exposed by the page through [WebMCP](/v4/basics/webmcp).
Fill Stripe Checkout through WebMCP tools and submit payment with Stagehand.
## Build your own integration
Use the [Stagehand SDK](/v4/first-steps/quickstart) to add browser automation to your application. Combine [Act](/v4/basics/act), [Extract](/v4/basics/extract), and [Observe](/v4/basics/observe) with direct [page interactions](/v4/reference/page) to navigate, interact, and return structured data.
For pages that publish their own tools, use [WebMCP](/v4/basics/webmcp) to discover and invoke them. Choose a local browser or a Browserbase session in [browser configuration](/v4/configuration/browser).
# Migrate Playwright to v4
Source: https://docs.stagehand.dev/v4/migrations/playwright
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()`](/v4/basics/act), [`extract()`](/v4/basics/extract), and [`observe()`](/v4/basics/observe): natural-language steps that survive the UI changes that break selectors, plus [server-side caching](/v4/best-practices/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](/v4/reference/stagehand) 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
```typescript Playwright theme={null}
import { chromium } from "playwright";
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto("https://example.com");
await page.click("a");
console.log(await page.title());
await browser.close();
```
```typescript Stagehand v4 theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
const browser = await browserbase.launch({
apiKey: process.env.BROWSERBASE_API_KEY,
});
const stagehand = await Stagehand.create({ browser });
const page = await browser.context.newPage("https://example.com");
await page.locator("a").click();
console.log(await page.title());
await stagehand.close();
await browser.close();
```
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](#porting-rules) before doing it by hand.
### Recommended migration order
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:
```typescript theme={null}
import { chromium } from "playwright";
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto("https://news.ycombinator.com");
await page.getByRole("link", { name: "new" }).click();
await page.waitForLoadState("domcontentloaded");
const titles = await page.locator(".titleline > a").allTextContents();
console.log(titles.slice(0, 5));
await browser.close();
```
The v4 port keeps the stable selector and spends a model call only where Playwright's `getByRole()` did the work:
```typescript theme={null}
import { browserbase, Stagehand } from "@browserbasehq/stagehand";
import { z } from "zod/v4";
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");
// getByRole() has no built-in equivalent, and the link has no stable selector.
await stagehand.act("Click the 'new' link in the top navigation");
await page.waitForLoadState("domcontentloaded");
// extract() replaces the allTextContents() loop and returns typed data in one call.
const { data } = await stagehand.extract(
"Extract the titles of the first five stories",
z.object({ titles: z.array(z.string()) }),
);
console.log(data.titles);
} finally {
await stagehand.close();
await browser.close();
}
```
## 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:
```diff theme={null}
- const browser = await chromium.launch({ headless: false });
+ const browser = await localBrowser.launch({ headless: false });
+ const stagehand = await Stagehand.create({ browser });
```
`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](/v4/best-practices/caching) and the [Model Gateway](/v4/configuration/models#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](/v4/configuration/browser).
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.
```diff theme={null}
- const context = await browser.newContext();
- const page = await context.newPage();
- await page.goto("https://example.com");
+ const page = await browser.context.newPage("https://example.com");
```
`newPage()` takes an optional URL, which saves a `goto()`. The rest of the surface maps closely:
| Playwright | Stagehand v4 |
| - | - |
| `context.pages()` | `await browser.context.pages()` |
| `page.bringToFront()` | `await browser.context.setActivePage(page)` |
| The page you last used | `await browser.context.activePage()` |
| `context.addCookies()`, `cookies()`, `clearCookies()` | Same names on `browser.context` |
| `context.addInitScript()`, `setExtraHTTPHeaders()` | Same names on `browser.context` |
`act()`, `extract()`, and `observe()` run against the active page unless you pass one: `stagehand.act("...", { page })`. See [using multiple tabs](/v4/best-practices/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:
```diff theme={null}
- page.getByTestId("submit")
+ page.locator("[data-testid=submit]")
```
**Use `text=`**, which matches a case-insensitive substring of the element's text:
```diff theme={null}
- page.getByText("Add to cart")
+ page.locator("text=Add to cart")
```
**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:
```diff theme={null}
- await page.getByRole("button", { name: "Add to cart" }).click();
+ const { data: actions } = await stagehand.observe("the add to cart button");
+ await page.locator(actions[0].selector).click();
```
Passing that action straight to `act()` replays it with no further inference. See [observe use cases](/v4/best-practices/usecase-observe).
**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](#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](#filter-and-strict-mode). `first()` and `nth()` work; build `last()` and `all()` from `count()`:
```diff theme={null}
- const titles = await page.locator(".titleline > a").allTextContents();
+ const links = page.locator(".titleline > a");
+ const count = await links.count();
+ const titles = [];
+ for (let i = 0; i < count; i++) {
+ titles.push(await links.nth(i).innerText());
+ }
```
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.
```diff theme={null}
- await page.goto(url); // waits for load
+ await page.goto(url, { waitUntil: "load" }); // v4 defaults to domcontentloaded
```
Wait first, then act:
```diff theme={null}
- await page.locator("#results").click();
+ await page.waitForSelector("#results", { state: "visible" });
+ await page.locator("#results").click();
```
`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](#auto-waiting-and-retries) 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`](/v4/configuration/browser#dom-settle-timeout) 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:
```diff theme={null}
- await expect(page.locator(".cart-count")).toHaveText("1");
+ await page.waitForSelector(".cart-count", { state: "visible" });
+ expect(await page.locator(".cart-count").innerText()).toBe("1");
```
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:
```typescript theme={null}
const { data } = await stagehand.extract(
"Extract the cart item count and order total",
z.object({ itemCount: z.number(), total: z.number() }),
);
expect(data.itemCount).toBe(1);
```
### 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:
```diff theme={null}
- await context.route("**/analytics/**", (route) => route.abort());
+ await browser.context.setDomainPolicy({ blockedDomains: ["analytics.example.com"] });
```
`storageState()` has no equivalent either. Persist auth with the cookie API, or with a Browserbase context for full profile reuse. See [user data](/v4/best-practices/user-data).
```diff theme={null}
- await context.storageState({ path: "auth.json" });
+ const cookies = await browser.context.cookies();
+ await fs.writeFile("auth.json", JSON.stringify(cookies));
```
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:
```diff theme={null}
- page.on("dialog", (dialog) => dialog.accept());
+ await browser.context.addInitScript(() => {
+ window.confirm = () => true;
+ window.alert = () => undefined;
+ });
```
`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](/v4/configuration/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()`:
```typescript theme={null}
const TAG = "data-sh-tag";
// Tag every element matching a role and accessible name, then return one locator per match.
async function locateByRole(page: Page, role: string, name?: string): Promise {
const count = await page.evaluate(
({ tag, role, name }) => {
const normalize = (value: string) => value.replace(/\s+/g, " ").trim().toLowerCase();
const roleOf = (element: Element) => {
const explicit = element.getAttribute("role");
if (explicit) return explicit.trim().split(/\s+/)[0];
const tagName = element.tagName.toLowerCase();
if (tagName === "button") return "button";
if (tagName === "a") return element.hasAttribute("href") ? "link" : undefined;
if (/^h[1-6]$/.test(tagName)) return "heading";
if (tagName === "textarea") return "textbox";
if (tagName === "select") return element.hasAttribute("multiple") ? "listbox" : "combobox";
if (tagName !== "input") return undefined;
const type = (element.getAttribute("type") || "text").toLowerCase();
if (["button", "submit", "reset", "image"].includes(type)) return "button";
if (["checkbox", "radio"].includes(type)) return type;
return ["hidden", "file"].includes(type) ? undefined : "textbox";
};
const labelOf = (element: Element) => {
const id = element.getAttribute("id");
const explicit = id && document.querySelector(`label[for="${id}"]`);
return explicit?.textContent || element.closest("label")?.textContent || "";
};
const nameOf = (element: Element) =>
element.getAttribute("aria-label") ||
labelOf(element) ||
element.getAttribute("alt") ||
element.textContent ||
"";
const isVisible = (element: Element) => {
const style = getComputedStyle(element);
if (style.visibility === "hidden" || style.display === "none") return false;
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const matches = [...document.querySelectorAll("*")].filter(
(element) =>
roleOf(element) === role &&
isVisible(element) &&
(name === undefined || normalize(nameOf(element)).includes(normalize(name))),
);
matches.forEach((element, index) => element.setAttribute(tag, String(index)));
return matches.length;
},
{ tag: TAG, role, name },
);
return Array.from({ length: count }, (_, index) => page.locator(`[${TAG}="${index}"]`));
}
// Drop the attributes once you're done with the locators.
async function clearTags(page: Page): Promise {
await page.evaluate(
(tag) => document.querySelectorAll(`[${tag}]`).forEach((el) => el.removeAttribute(tag)),
TAG,
);
}
```
```typescript theme={null}
const [addToCart] = await locateByRole(page, "button", "Add to cart");
await addToCart.click();
await clearTags(page);
```
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 `` 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 `
` 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.
```typescript theme={null}
async function locateByText(page: Page, text: string): Promise {
const count = await page.evaluate(
({ tag, text }) => {
const normalize = (value: string) => value.replace(/\s+/g, " ").trim().toLowerCase();
const target = normalize(text);
const hasText = (element: Element) => normalize(element.textContent).includes(target);
const matches = [...document.querySelectorAll("*")].filter(
(element) => hasText(element) && ![...element.children].some(hasText),
);
matches.forEach((element, index) => element.setAttribute(tag, String(index)));
return matches.length;
},
{ tag: TAG, text },
);
return Array.from({ length: count }, (_, index) => page.locator(`[${TAG}="${index}"]`));
}
```
### 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:
```typescript theme={null}
async function filterByText(locator: Locator, hasText: string): Promise {
const normalize = (value: string) => value.replace(/\s+/g, " ").trim().toLowerCase();
const target = normalize(hasText);
const total = await locator.count();
const kept: Locator[] = [];
for (let index = 0; index < total; index++) {
const candidate = locator.nth(index);
if (normalize(await candidate.textContent()).includes(target)) kept.push(candidate);
}
return kept;
}
```
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:
```typescript theme={null}
const submit = page.locator("button.submit");
const matches = await submit.count();
if (matches !== 1) throw new Error(`strict mode violation: ${matches} elements matched`);
await submit.click();
```
### 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.
```typescript theme={null}
// A re-render between the check and the action produces one of these. Anything
// else is a real failure and must not wait out the deadline.
const RETRYABLE = /detached|not visible|could not find|no element|timed? out/i;
async function actWhenReady(
page: Page,
locator: Locator,
action: (target: Locator) => Promise,
timeout = 30_000,
): Promise {
const deadline = Date.now() + timeout;
let lastError;
while (Date.now() < deadline) {
if ((await locator.count()) > 0 && (await locator.isVisible())) {
try {
await action(locator);
return;
} catch (error) {
if (!RETRYABLE.test(String(error))) throw error;
lastError = error;
}
}
await page.waitForTimeout(50);
}
// Surface the real failure. A generic timeout would hide which locator broke and why.
throw lastError ?? new Error(`Timed out after ${timeout}ms waiting for an actionable element`);
}
await actWhenReady(page, page.locator("#checkout"), (target) => target.click());
```
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()`:
```typescript theme={null}
const href = await page.evaluate(
(selector) => document.querySelector(selector).getAttribute("href"),
"a.download",
);
const enabled = await page.evaluate(
(selector) => !document.querySelector(selector).matches(":disabled"),
"button.submit",
);
const box = await page.evaluate((selector) => {
const { x, y, width, height } = document.querySelector(selector).getBoundingClientRect();
return { x, y, width, height };
}, "#banner");
```
### The remaining equivalences
Each of these is a direct substitution:
| Playwright | Stagehand v4 |
| - | - |
| `locator.check()` | `if (!(await locator.isChecked())) await locator.click()` |
| `locator.uncheck()` | `if (await locator.isChecked()) await locator.click()` |
| `locator.clear()` | `locator.fill("")` |
| `locator.press(key)` | `await locator.click()`, then `page.keyPress(key)`. Combos such as `"Control+A"` work |
| `locator.pressSequentially(text)` | `locator.type(text)` |
| `locator.dblclick()` | `locator.click({ clickCount: 2 })` |
| `locator.dispatchEvent("click")` | `locator.sendClickEvent()` |
| `locator.last()` | `locator.nth(await locator.count() - 1)` |
| `locator.allTextContents()` | `page.evaluate((s) => [...document.querySelectorAll(s)].map((el) => el.textContent), selector)` |
| `locator.selectOption({ index })` | Read `select.options[index].value` with `evaluate()`, then pass that value |
| `locator.focus()`, `blur()` | `page.evaluate((s) => document.querySelector(s).focus(), selector)` |
| `page.content()` | `page.evaluate(() => document.documentElement.outerHTML)` |
| `page.$eval(selector, fn)` | `page.evaluate()` with the selector as its argument |
| `page.getByTestId(x)` | `page.locator('[data-testid="x"]')` |
| `page.getByPlaceholder(x)` | `page.locator('[placeholder*="x"]')`, substring like Playwright's default |
| `page.getByAltText(x)`, `getByTitle(x)` | `page.locator('[alt*="x"]')`, `page.locator('[title*="x"]')` |
| `page.getByLabel(x)` | Tag with the `label[for]` and wrapping-label lookup from `locateByRole()` |
| `page.waitForNavigation()` | Poll `page.url()` every 50 ms until it changes |
| `page.waitForFunction(fn)` | Poll `page.evaluate(fn)` on the same 50 ms loop |
| `keyboard.press(key)`, `keyboard.type(text)` | `page.keyPress(key)`, `page.type(text)` |
| `mouse.move(x, y)`, `mouse.click(x, y)` | `page.hover(x, y)`, `page.click(x, y)` |
| `mouse.wheel(dx, dy)` | `page.scroll(x, y, dx, dy)` |
| `request.fetch(url)` | `page.evaluate(async (u) => (await fetch(u)).text(), url)` |
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()`](/v4/reference/stagehand#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](/v4/best-practices/cost-optimization).
## Quick reference
"No equivalent" here means no built-in method. [Rebuilding the missing APIs](#rebuilding-the-missing-apis) implements most of them in a few lines.
### Setup and lifecycle
| Playwright | Stagehand v4 |
| - | - |
| `chromium.launch()` | `localBrowser.launch()` or `browserbase.launch({ apiKey })` |
| `chromium.connectOverCDP(url)` | `localBrowser.connect({ cdpUrl })` |
| `firefox`, `webkit` | Not supported. Chromium only |
| `npx playwright install` | Not needed. Local uses installed Chrome |
| `browser.newContext()` | One context per browser: `browser.context` |
| `context.newPage()` | `browser.context.newPage()`, optionally with a URL |
| `browser.close()` | `await stagehand.close()`, then `await browser.close()` |
### Navigation and page state
| Playwright | Stagehand v4 |
| - | - |
| `page.goto()`, `reload()`, `goBack()`, `goForward()` | Same names |
| `page.url()`, `page.title()` | Same names, both async |
| `page.goto()` default `waitUntil` | `"domcontentloaded"`, not Playwright's `"load"` |
| `waitUntil: "commit"` | Not supported. `load`, `domcontentloaded`, and `networkidle` only |
| `page.waitForURL()` | Poll `page.url()`, or wait for a destination selector |
| `page.setContent()` | No equivalent. Navigate, or build the DOM in `page.evaluate()` |
| `page.evaluate()`, `addInitScript()` | Same names |
| `page.$eval()`, `$$eval()` | `page.evaluate()` |
| `page.setViewportSize()` | Same name, two arguments instead of an object |
| `page.setExtraHTTPHeaders()` | Same name, also on the context |
| `page.screenshot()` | Same, returns bytes and honors `path` |
| `page.pdf()` | No equivalent |
| `page.accessibility.snapshot()` | `page.snapshot()`, returns a tree plus an XPath map |
### Locators
| Playwright | Stagehand v4 |
| - | - |
| `page.locator(css)` | Same, and it pierces shadow DOM |
| `page.locator("xpath=...")` | Same, or a leading `/` |
| `page.locator("text=...")` | Same, matches a case-insensitive substring |
| `page.getByRole()`, `getByLabel()` | `observe()`, or [rebuild the query](#the-tag-and-locate-pattern) |
| `page.getByPlaceholder()`, `getByAltText()`, `getByTitle()` | `page.locator('[placeholder*="..."]')`, and the same for `alt` and `title` |
| `page.getByText()` | `page.locator("text=...")`, plus the [leaf rule](#getbytext-and-the-smallest-match-rule) |
| `page.getByTestId()` | `page.locator("[data-testid=...]")` |
| `locator.locator()`, `filter()`, `and()`, `or()` | One selector, `observe()`, or [a count and nth loop](#filter-and-strict-mode) |
| `locator.first()`, `nth()` | Same names |
| `locator.last()` | `nth(count - 1)` |
| `locator.all()`, `allTextContents()` | `count()` plus `nth()`, or `extract()` |
| Strict mode | Not enforced. [Assert `count() === 1`](#filter-and-strict-mode) |
| `frameLocator("iframe").locator(x)` | `page.locator("iframe >> x")` |
| `page.frames()` | No equivalent. Selectors cross frames |
### Actions
| Playwright | Stagehand v4 |
| - | - |
| `page.click(selector)` | `page.locator(selector).click()`. `page.click(x, y)` takes coordinates |
| `page.fill(selector, value)` | `page.locator(selector).fill(value)` |
| `page.type(selector, text)` | `page.locator(selector).type(text)` |
| `locator.click()`, `hover()`, `fill()`, `type()` | Same names |
| `locator.dblclick()` | `locator.click({ clickCount: 2 })` |
| `locator.press("Enter")` | `locator.click()`, then `page.keyPress("Enter")` |
| `locator.check()`, `uncheck()` | `locator.click()`, confirm with `isChecked()` |
| `locator.clear()` | `locator.fill("")` |
| `locator.selectOption()`, `setInputFiles()` | Same names |
| `locator.dragTo()` | `page.dragAndDrop(fromX, fromY, toX, toY)` with `locator.centroid()` |
| `locator.scrollIntoViewIfNeeded()` | Automatic. `locator.scrollTo()` scrolls within an element |
| `locator.dispatchEvent("click")` | `locator.sendClickEvent()` |
| `mouse`, `keyboard`, `touchscreen` | `page.click(x, y)`, `hover()`, `scroll()`, `type()`, `keyPress()` |
### Waiting and assertions
| Playwright | Stagehand v4 |
| - | - |
| Auto-waiting on every action | Not automatic. `page.waitForSelector()`, or a [retry loop](#auto-waiting-and-retries) |
| `page.waitForSelector()` | Same name, returns a boolean |
| `page.waitForLoadState()`, `waitForTimeout()` | Same names |
| `page.setDefaultTimeout()`, `setDefaultNavigationTimeout()` | No equivalent. Pass `timeout` per call |
| `page.waitForFunction()` | Poll `page.evaluate()` |
| `page.waitForResponse()`, `waitForRequest()`, `waitForEvent()` | No equivalent |
| `page.on("request")`, `on("response")`, `on("dialog")` | `page.on("console")` only |
| `expect(locator).toBeVisible()` | `waitForSelector()`, then your runner's assertion |
| `expect(locator).toHaveText()` | `innerText()`, or `extract()` with a schema |
### Reading state
| Playwright | Stagehand v4 |
| - | - |
| `locator.textContent()`, `innerText()`, `inputValue()`, `isChecked()`, `isVisible()`, `count()` | Same names |
| `locator.innerHTML()` | `locator.innerHtml()` |
| `locator.getAttribute()`, `isEnabled()`, `isEditable()` | [`page.evaluate()`](#element-reads-through-evaluate) |
| `locator.boundingBox()` | `locator.centroid()` for the center point |
### Storage, network, and tooling
| Playwright | Stagehand v4 |
| - | - |
| `context.cookies()`, `addCookies()`, `clearCookies()` | Same names on `browser.context` |
| `context.storageState()` | Cookie API, or a [Browserbase context](/v4/best-practices/user-data) |
| `page.route()`, `context.route()`, request mocking | No equivalent. `context.setDomainPolicy()` blocks whole domains |
| `page.on("dialog")`, `dialog.accept()` | Stub `window.confirm` and `window.alert` with `addInitScript()` |
| `page.waitForEvent("download")`, `download.saveAs()` | No equivalent. Set `acceptDownloads` and `downloadsPath` at launch |
| `@playwright/test`, fixtures, `expect` | Bring your own runner, such as Vitest or Jest |
| Trace viewer, `page.video()`, `page.pause()` | Browserbase session recordings and `stagehand.metrics()` |
| `npx playwright codegen` | A coding assistant with [AI rules](/v4/first-steps/ai-rules), or `observe()` |
## 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](/v4/first-steps/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
```text theme={null}
Port this Playwright script to Stagehand v4 (@browserbasehq/stagehand).
Apply the porting rules below verbatim; they override anything you remember
about Stagehand. Keep every selector as-is. Where a Playwright API has no
equivalent, comment out the line and add a TODO naming the API instead of
inventing one. List every TODO you left when you're done.
```
### 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:
```text theme={null}
PORTING PLAYWRIGHT TO STAGEHAND V4
Stagehand v4 has no Playwright interop. Port the code; never pass a Playwright
object to Stagehand. Keep every original selector unless a rule below says
otherwise. Where an API has no equivalent, comment out the original line and
leave a TODO naming it. Do not invent APIs.
SETUP AND LIFECYCLE
- import { browserbase, localBrowser, Stagehand } from "@browserbasehq/stagehand".
- chromium.launch(opts) -> localBrowser.launch(opts), or
browserbase.launch({ apiKey }) for a hosted browser.
- chromium.connectOverCDP(url) -> localBrowser.connect({ cdpUrl }).
Existing Browserbase session -> browserbase.connect({ apiKey, sessionId }).
- After launching: const stagehand = await Stagehand.create({ browser }).
- localBrowser.launch() accepts args, executablePath, port, userDataDir,
preserveUserDataDir, headless, devtools, chromiumSandbox, ignoreDefaultArgs,
proxy, locale, viewport, deviceScaleFactor, hasTouch, ignoreHTTPSErrors,
downloadsPath, acceptDownloads, and keepAlive.
- Stagehand.create() accepts model, cache, logging, systemPrompt, selfHeal,
domSettleTimeoutMs, and apiKey. Stagehand reads no environment variables;
pass every key explicitly.
- Teardown: await stagehand.close() then await browser.close().
stagehand.close() leaves the browser running.
- Chromium only. Do not port firefox or webkit projects. No browser download
step: local runs use installed Chrome.
CONTEXTS AND PAGES
- One context per browser, at browser.context. Never call browser.newContext().
Separate profiles need separate browsers.
- context.newPage() -> browser.context.newPage(url?), URL optional.
- context.pages() -> await browser.context.pages() (async).
- The current page -> await browser.context.activePage().
- page.bringToFront() -> await browser.context.setActivePage(page).
- context.addCookies, cookies, clearCookies, addInitScript, and
setExtraHTTPHeaders keep their names on browser.context.
- act(), observe(), and extract() target the active page unless you pass
{ page }: stagehand.act("...", { page }).
RESULT SHAPES
- act(), observe(), and extract() return { data, metadata }. Read .data.
metadata carries the action id, cache status, and token usage.
- extract() is positional: extract(instruction, schema, options?). With no
schema it returns { extraction: string }.
- observe() returns Action[] on .data. Each Action has selector, description,
and optional method and arguments.
- act() accepts a string or an Action. Passing an Action replays it with no
inference.
- act(), observe(), and extract() accept model, timeout, cache, locator, and
ignoreLocators. act() and observe() also accept variables, which keeps
secrets out of the instruction.
SELECTORS
- page.locator() accepts CSS, xpath= or a leading /, css=, and text=.
It pierces shadow DOM including closed roots. Use >> to hop into an iframe:
page.locator("iframe#checkout >> button.submit").
- frameLocator("f").locator("x") -> page.locator("f >> x").
There is no page.frames() and no frame objects.
- getByTestId(x) -> page.locator('[data-testid="x"]').
- getByPlaceholder(x) -> page.locator('[placeholder*="x"]').
Same shape for getByAltText -> [alt*="x"] and getByTitle -> [title*="x"].
Use *= because Playwright's default is a substring match.
- getByText(x) -> page.locator("text=x"), which is a case-insensitive
substring match. WARNING: it also matches wrapper elements, so it can
resolve a container instead of the leaf. When the port depends on
Playwright's leaf match, use tagAndLocate below with the leaf rule.
- getByRole and getByLabel have no selector form. Options, in order:
1. Rewrite as CSS when the markup is stable.
2. const { data } = await stagehand.observe(""), then
page.locator(data[0].selector), or hand data[0] to act().
3. Rebuild with tagAndLocate below.
- No locator.locator(), filter(), and(), or or(). Collapse to one selector,
or filter with a count() and nth() loop.
- first() and nth() exist. last() is nth(await locator.count() - 1).
all() and allTextContents() are a count() and nth() loop, or one extract().
- STRICT MODE IS NOT ENFORCED. A locator matching several elements acts on the
first. Where the original relied on strict mode, assert count() === 1 first.
- Stuck finding a target? Read (await page.snapshot()).formattedTree, which is
the accessibility tree, and use the paired xpathMap.
TAG AND LOCATE (rebuilding getByRole, getByLabel, and any custom query)
page.evaluate() returns JSON and cannot hand back an element, so run the query
in the page, stamp matches with an attribute, then address them with a locator:
const TAG = "data-sh-tag";
// Write the query inline in the callback. Do not build it with new Function()
// or eval(): that is page-level eval and a strict CSP blocks it.
async function locateByRole(page, role, name) {
const count = await page.evaluate(
({ tag, role, name }) => {
const normalize = (v) => v.replace(/\s+/g, " ").trim().toLowerCase();
const roleOf = (el) => { /* implicit role map, see below */ };
const nameOf = (el) => { /* accessible name precedence, see below */ };
const isVisible = (el) => { /* see below */ };
const matches = [...document.querySelectorAll("*")].filter(
(el) =>
roleOf(el) === role &&
isVisible(el) &&
(name === undefined || normalize(nameOf(el)).includes(normalize(name))),
);
matches.forEach((el, i) => el.setAttribute(tag, String(i)));
return matches.length;
},
{ tag: TAG, role, name },
);
return Array.from({ length: count }, (_, i) => page.locator(`[${TAG}="${i}"]`));
}
async function clearTags(page) {
await page.evaluate(
(tag) => document.querySelectorAll(`[${tag}]`).forEach((el) => el.removeAttribute(tag)),
TAG,
);
}
Rules for it:
- page.evaluate() serializes the callback and JSON-stringifies its argument, so
the callback captures nothing from the calling scope. Pass everything it needs
as the second argument, and return only JSON.
- Write one helper per query shape (role, label, leaf text) on this pattern.
- Tag immediately before acting and clear afterwards. A tag written before a
re-render points at a detached node.
- Implicit roles: button->button, a[href]->link, h1-h6->heading,
textarea->textbox, select->combobox (listbox when multiple), input by type
(button/submit/reset/image->button, checkbox, radio, range->slider,
number->spinbutton, search->searchbox, otherwise textbox). An explicit
role attribute wins.
- Accessible name precedence: aria-label, then associated label text
(aria-labelledby ids, then label[for=id], then a wrapping label), then alt,
then value on an of type button, submit, or reset, then textContent,
then title. Playwright's name
option is a case-insensitive substring unless exact: true.
- Normalize text before comparing: value.replace(/\s+/g, " ").trim() and
lowercase, matching Playwright.
- Leaf rule for text queries: keep an element only when no child also matches.
- Visibility: getComputedStyle display and visibility, plus a
getBoundingClientRect with non-zero width and height.
- document.querySelectorAll() stops at the main document. Recurse
element.shadowRoot for open roots. Closed roots are unreachable from
evaluate(), so use page.locator() for those.
ACTIONS
- page.click(selector) -> page.locator(selector).click().
page.click(x, y) takes COORDINATES. Same for page.hover(x, y).
page.type(text) types at the focused element. Never pass a selector to these.
- Locator has click, hover, fill, type, selectOption, setInputFiles, scrollTo,
centroid, highlight, sendClickEvent, count, isChecked, isVisible, innerText,
innerHtml, textContent, inputValue, first, and nth.
- locator.press(key) -> await locator.click() then page.keyPress(key).
Combos work: page.keyPress("Control+A").
- locator.pressSequentially(text) -> locator.type(text).
- locator.check() -> if (!(await locator.isChecked())) await locator.click().
locator.uncheck() is the same with the condition inverted.
- locator.clear() -> locator.fill("").
- locator.dblclick() -> locator.click({ clickCount: 2 }).
- locator.dispatchEvent("click") -> locator.sendClickEvent().
- locator.dragTo(target) -> page.dragAndDrop(fromX, fromY, toX, toY) using
coordinates from locator.centroid().
- locator.scrollIntoViewIfNeeded() -> drop it. Actions scroll their target
into view. locator.scrollTo(percent) is unrelated: it scrolls content
inside a scrollable element.
- locator.selectOption({ index }) -> read select.options[index].value with
evaluate(), then pass that value.
- locator.setInputFiles() keeps its name and also takes in-memory payloads
({ name, mimeType, buffer }). Cap is 50 MiB per file.
- keyboard.press/type -> page.keyPress/page.type.
mouse.move/click -> page.hover(x, y)/page.click(x, y).
mouse.wheel(dx, dy) -> page.scroll(x, y, dx, dy).
NAVIGATION AND WAITING
- goto, reload, goBack, and goForward keep their names and return the main
document Response or null.
- DEFAULT LOAD STATE DIFFERS. Playwright goto() waits for "load"; v4 waits for
"domcontentloaded". Pass { waitUntil: "load" } where the original relied on
subresources. Valid states: load, domcontentloaded, networkidle.
waitUntil: "commit" does not exist.
- page.url() and page.title() are async. Await them.
- NOTHING AUTO-WAITS. Stagehand resolves a selector once and throws
"Could not find an element for the given xPath(s)". Before acting on or
reading an element that may not be present, call
await page.waitForSelector(selector, { state: "visible" }).
It returns a boolean, not an element handle.
- One wait is not Playwright's per-action retry. For a step that races a
re-render, wrap it:
const RETRYABLE = /detached|not visible|could not find|no element|timed? out/i;
async function actWhenReady(page, locator, action, timeout = 30_000) {
const deadline = Date.now() + timeout;
let lastError;
while (Date.now() < deadline) {
if ((await locator.count()) > 0 && (await locator.isVisible())) {
try { await action(locator); return; }
catch (error) {
if (!RETRYABLE.test(String(error))) throw error;
lastError = error;
}
}
await page.waitForTimeout(50);
}
throw lastError ?? new Error(`Timed out after ${timeout}ms waiting for an actionable element`);
}
Both guards are required. Without RETRYABLE, the loop swallows assertion and
logic failures until the deadline, turning a real bug into a timeout. Without
lastError, a recurring failure loses its message to the generic timeout.
NEVER retry act(): a failed act() may already have clicked or submitted.
Retry observe() instead, then pass its Action to act() once.
- locator.waitFor({ state }) -> the same loop. count() for attached and
detached; count() plus isVisible() for visible and hidden.
- page.waitForFunction(fn) -> poll page.evaluate(fn) on that loop.
- page.waitForNavigation() -> poll page.url() until it changes.
- page.waitForURL() -> poll page.url(), or wait for a selector that exists
only on the destination.
- No waitForResponse, waitForRequest, or waitForEvent. page.on() supports
"console" only: no request, response, requestfailed, download, dialog,
popup, or pageerror events, and no CDP escape hatch.
- No setDefaultTimeout or setDefaultNavigationTimeout. Pass timeout per call.
waitForLoadState takes it positionally: waitForLoadState(state, timeout).
domSettleTimeoutMs on Stagehand.create() covers DOM settling.
ASSERTIONS AND READING STATE
- expect() does not exist, and neither does assertion retry. Read state, then
assert with the runner already in the project (Vitest, Jest).
- Available directly: textContent, innerText, innerHtml (note the casing),
inputValue, isChecked, isVisible, count.
- Via page.evaluate(): getAttribute, isEnabled and isDisabled
(element.matches(":disabled")), isEditable, allTextContents
([...document.querySelectorAll(s)].map((el) => el.textContent)),
focus, blur, page.content() (document.documentElement.outerHTML),
and $eval / $$eval.
- locator.boundingBox() -> locator.centroid() for the center point, or
getBoundingClientRect() inside evaluate() for the full box.
- When the assertion is about page content rather than DOM structure, prefer
extract() with a schema and assert on the typed result.
NETWORK, STORAGE, AND FILES
- No page.route(), context.route(), or request mocking. The nearest control is
await browser.context.setDomainPolicy({ allowedDomains, blockedDomains }).
- No context.storageState(). Persist auth with browser.context.cookies() and
addCookies(), or a Browserbase context for full profile reuse.
- request.fetch(url) -> page.evaluate(async (u) => (await fetch(u)).text(), url).
- page.on("dialog") -> stub the dialog functions before load:
await browser.context.addInitScript(() => { window.confirm = () => true; });
- Downloads are launch-time only: acceptDownloads plus downloadsPath on
localBrowser.launch(), and downloadsPath is required when acceptDownloads is
true. There is no download event and no download.saveAs().
- page.setViewportSize(width, height) takes two arguments, not an object.
- page.screenshot(options?) keeps its name and returns bytes (Uint8Array).
fullPage, clip, mask, maskColor, omitBackground, type, quality, scale, style,
animations, caret, path, and timeout are all supported.
- page.accessibility.snapshot() -> page.snapshot(), which returns
formattedTree, xpathMap, and urlMap.
- No page.pdf(), page.setContent(), page.pause(), tracing, or video. For
page.setContent(), navigate to a real URL or build the DOM in evaluate().
WHAT TO USE INSTEAD OF SELECTORS
- Prefer page.locator() wherever a selector is stable. A model call per step
costs latency and money that a CSS selector does not.
- Replace the steps that break most often with act(), and set selfHeal: true so
a recorded action re-infers when its selector breaks.
- Replace a count()/nth() scraping loop with one extract() call and an array
schema.
- Set cache: true on Stagehand.create() once a flow is stable, then check
metadata.cache.status for HIT.
- For long stretches of deterministic calls, stagehand.experimentalBatch()
runs a callback next to the page and avoids a round trip per call. The
callback cannot capture outer variables; pass them as input. It is
experimental.
DEBUGGING WHAT YOU PORTED
- logging: { level: "debug" } on Stagehand.create(), locator.highlight() to see
what a selector resolved to, and await stagehand.metrics() for token usage
and inference timing.
- On Browserbase, the session inspector replaces the trace viewer: live view,
recording, and network detail.
```
## 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](/v4/first-steps/ai-rules).
## Next steps
The full deterministic surface
Get real selectors from a description
Replace scraping loops with typed data
Cut inference out of a stable flow
# Migrate v3 to v4
Source: https://docs.stagehand.dev/v4/migrations/v3
Two changes account for most of this migration:
1. **`agent()` is gone.** Nothing in v4 replaces it one-for-one.
2. **The SDK surface moved.** Construction, page access, and result shapes changed.
`act()`, `extract()`, and `observe()` still exist and still take natural-language instructions, but their place has changed.
## Why agent() is gone
`agent()` was built for models that couldn't reliably drive a browser on their own. It wrapped `act()`, `extract()`, and `observe()` in a loop and asked the model to pick one tool per step, which was the right shape for the models available at the time.
Now, that built-in orchestrator is gone. v4 exposes discrete tools and leaves the control flow to you. Keep calling `act()`, `extract()`, and `observe()` where a natural-language instruction beats a selector, but stop treating them as the whole toolset you hand a model.
Two approaches replace it:
* **[Code mode](#code-mode)** puts the model in front of the run. A coding assistant writes a Stagehand script, and you run that script. Browserbase recommends starting here.
* **[Tool calling](#tool-calling)** keeps a model in the loop during the run, driving the browser through the full Stagehand API as its tools.
## Code mode
Ask your coding assistant to write a 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). It carries the rule files and MCP servers that keep generated code on the v4 API instead of the v2 and v3 patterns in training data.
Here's a prompt that produces a working script:
```text theme={null}
Using Stagehand v4 (@browserbasehq/stagehand), 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 would have written yourself:
```typescript theme={null}
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();
}
```
```python theme={null}
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())
```
```go theme={null}
type comment struct {
Author string `json:"author"`
Body string `json:"body"`
}
type comments struct {
Comments []comment `json:"comments"`
}
func run(ctx context.Context) (err error) {
browser, err := stagehand.LaunchBrowserbase(ctx, stagehand.BrowserbaseLaunchOptions{
APIKey: os.Getenv("BROWSERBASE_API_KEY"),
})
if err != nil {
return err
}
defer func() { err = errors.Join(err, browser.Close(ctx)) }()
client, err := stagehand.Create(ctx, stagehand.CreateOptions{Browser: browser})
if err != nil {
return err
}
defer func() { err = errors.Join(err, client.Close(ctx)) }()
browserContext, err := browser.Context()
if err != nil {
return err
}
page, err := browserContext.NewPage(ctx, "https://news.ycombinator.com")
if err != nil {
return err
}
// Deterministic where the page allows it: no inference, no variance.
if err := page.Locator("a.morelink").First().Click(ctx, nil); err != nil {
return err
}
if err := page.WaitForLoadState(ctx, stagehand.LoadStateDOMContentLoaded, nil); err != nil {
return err
}
// A model call where the page needs judgement.
instruction := stagehand.ActInstruction("Open the comments for the story with the most comments")
if _, err := client.Act(ctx, instruction, nil); err != nil {
return err
}
result, err := stagehand.Extract[comments](
ctx,
client,
"Extract the top five comments, with each author and body",
nil,
)
if err != nil {
return err
}
fmt.Println(result.Data.Comments)
return nil
}
```
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. `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, give it the whole Stagehand surface rather than three broad tools. Each method maps to one tool with a narrow contract, so a step names a specific browser operation instead of routing through a sentence of English.
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. 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.
```typescript theme={null}
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,
},
};
```
```python theme={null}
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]
```
```go theme={null}
// One handler per tool. Register these with your agent framework's tool API.
func gotoTool(ctx context.Context, page *stagehand.Page, url string) (string, error) {
if _, err := page.Goto(ctx, url, nil); err != nil {
return "", err
}
return page.URL(ctx)
}
func snapshotTool(ctx context.Context, page *stagehand.Page) (string, error) {
snapshot, err := page.Snapshot(ctx, nil)
if err != nil {
return "", err
}
return snapshot.FormattedTree, nil
}
func clickTool(ctx context.Context, page *stagehand.Page, selector string) error {
return page.Locator(selector).Click(ctx, nil)
}
func fillTool(ctx context.Context, page *stagehand.Page, selector, value string) error {
return page.Locator(selector).Fill(ctx, value)
}
func readTextTool(ctx context.Context, page *stagehand.Page, selector string) (string, error) {
return page.Locator(selector).TextContent(ctx)
}
func actTool(ctx context.Context, client *stagehand.Stagehand, instruction string) (string, error) {
result, err := client.Act(ctx, stagehand.ActInstruction(instruction), nil)
if err != nil {
return "", err
}
return result.Data.Message, nil
}
```
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. [Cost optimization](/v4/best-practices/cost-optimization) applies the same idea to model escalation.
## Let a coding assistant do the rest
The rest of this guide is a mapping table, so hand it to a coding assistant. Point it at this page instead of retyping the rules:
```text theme={null}
Migrate this file from Stagehand v3 to v4.
Follow https://docs.stagehand.dev/v4/migrations/v3, and use its quick
reference table as the mapping. Leave agent() calls alone and list them
for me instead.
```
Set up [AI rules](/v4/first-steps/ai-rules) first so the assistant stays on the v4 API instead of the v2 and v3 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 changing any instructions.
2. Replace page and context access, since it moved.
3. Unwrap results: every primitive now returns `{ data, metadata }`.
4. Replace `agent()` calls with [code mode](#code-mode) or [tool calling](#tool-calling).
5. Confirm server-side caching is doing what you expect via `metadata.cache.status`.
## Coming from Python or Go
v3 shipped Python and Go as clients for the hosted Stagehand API. You created a session and called methods on it.
Python:
```python theme={null}
from stagehand import AsyncStagehand
client = AsyncStagehand()
session = await client.sessions.create(model_name="openai/gpt-5-nano")
await session.navigate(url="https://example.com")
act_response = await session.act(input="click the sign in button")
```
Go:
```go theme={null}
client := stagehand.NewClient(
option.WithBrowserbaseAPIKey(os.Getenv("BROWSERBASE_API_KEY")),
option.WithModelAPIKey(os.Getenv("MODEL_API_KEY")),
)
started, err := client.Sessions.Start(ctx, stagehand.SessionStartParams{ModelName: "openai/gpt-5-nano"})
sessionID := started.Data.SessionID
_, err = client.Sessions.Navigate(ctx, sessionID, stagehand.SessionNavigateParams{
URL: "https://example.com",
})
```
v4 gives all three languages the same SDK: a browser from a factory, a Stagehand instance built on it, and `act()`, `extract()`, and `observe()` on that instance. Sessions and their IDs are gone from the calling surface, and the package names changed, so this is a rewrite against the new shape rather than a rename pass. The [code mode](#code-mode) examples show the target in each language.
| v3 | v4 |
| - | - |
| `AsyncStagehand()` / `stagehand.NewClient(...)` | A browser factory, then `Stagehand.create()` |
| `client.sessions.create()` / `client.Sessions.Start()` | The browser factory returns the handle; no session object |
| `session.navigate(url=...)` | `page.goto(url)` |
| `session.act(...)` / `session.extract(...)` | `stagehand.act(...)` / `stagehand.extract(...)` |
| `response.data.result` | `result.data` |
## Breaking changes
These diffs are TypeScript, because v3's TypeScript SDK is the one whose surface maps onto v4 rename by rename. The v4 side of each diff is the shape for every language.
### Initialization
The constructor is private and `init()` is gone. Get a browser from a factory, then hand it to `Stagehand.create()`:
```diff theme={null}
- const stagehand = new Stagehand({ env: "BROWSERBASE" });
- await stagehand.init();
+ const browser = await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY });
+ const stagehand = await Stagehand.create({ browser });
```
Use `localBrowser.launch()` for a browser on your machine, and `localBrowser.connect({ cdpUrl })` or `browserbase.connect({ apiKey, sessionId })` to attach to one that's already running. Stagehand closes only the browsers it launched, so call `browser.close()` yourself. See [browser configuration](/v4/configuration/browser).
### Pages and the browser context
The context moved off the Stagehand instance and onto the browser handle, and page lookups are now async:
```diff theme={null}
- const page = stagehand.context.pages()[0];
- const page = stagehand.page;
+ const page = await browser.context.activePage();
+ const [page] = await browser.context.pages();
```
If you only hold the instance, reach the handle through it: `stagehand.browser.context`.
### act(), extract(), and observe() moved to the instance
In v3 these hung off the page. In v4 they're top-level methods on Stagehand, and you pick the target page with `options.page` when it isn't the active one:
```diff theme={null}
- await page.act("Click the sign in button");
+ await stagehand.act("Click the sign in button");
+ await stagehand.act("Click the sign in button", { page: page2 });
```
### Every primitive returns data and metadata
`act()`, `extract()`, and `observe()` return `{ data, metadata }`. Your value is on `data`; `metadata` carries the action ID, cache status, and token usage.
```diff theme={null}
- const actions = await stagehand.observe("find the login button");
- if (actions.length > 0) { /* ... */ }
+ const { data: actions } = await stagehand.observe("find the login button");
+ if (actions.length > 0) { /* ... */ }
```
### extract() takes positional arguments
```diff theme={null}
- const result = await stagehand.extract({
- instruction: "extract the product name",
- schema: z.object({ name: z.string() }),
- });
+ const { data } = await stagehand.extract(
+ "extract the product name",
+ z.object({ name: z.string() }),
+ );
```
Calling `extract()` with no schema returns `{ extraction: string }`.
### Model configuration
`modelName` and `modelClientOptions` collapse into one `model` object:
```diff theme={null}
- const stagehand = new Stagehand({
- modelName: "openai/gpt-4.1-mini",
- modelClientOptions: { apiKey: process.env.OPENAI_API_KEY },
- });
+ const stagehand = await Stagehand.create({
+ browser,
+ model: { modelName: "openai/gpt-5.4-mini", apiKey: process.env.OPENAI_API_KEY },
+ });
```
Model names always carry a provider prefix. Omit `model` entirely on a Browserbase browser and the Model Gateway picks one for you. Pass the same shape to a single call to override it there. See [models](/v4/configuration/models).
### Caching
v3's server-side cache option, `serverCache`, is now `cache`, and the local `enableCaching`/`cacheDir` options are gone. v4 caches `act()`, `observe()`, and `extract()` results on Browserbase's servers, keyed on the instruction, page content, and options:
```diff theme={null}
- const stagehand = new Stagehand({ env: "BROWSERBASE" });
- await stagehand.act("...", { serverCache: false });
+ const stagehand = await Stagehand.create({ browser });
+ await stagehand.act("...", { cache: false });
```
Caching stays on by default, and each call can opt out. Caching needs a Browserbase browser and the API key you passed to `browserbase.launch()`. See [caching](/v4/best-practices/caching).
### Logging
`verbose` and `logger` become one `logging` object with a level, a format, and a callback:
```diff theme={null}
- const stagehand = new Stagehand({ verbose: 1, logger: myLogger });
+ const stagehand = await Stagehand.create({
+ browser,
+ logging: { level: "info", format: "json", onLog: myLogger },
+ });
```
Levels are `debug`, `info`, `warn`, `error`, and `off`. See [logging](/v4/configuration/logging).
### Metrics
Metrics became a method:
```diff theme={null}
- const metrics = await stagehand.metrics;
+ const metrics = await stagehand.metrics();
```
### The Browserbase session ID
`stagehand.browserbaseSessionID` is gone. Create the session yourself when you need its ID, then attach:
```diff theme={null}
- const stagehand = new Stagehand({ env: "BROWSERBASE" });
- await stagehand.init();
- console.log(stagehand.browserbaseSessionID);
+ import { Browserbase } from "@browserbasehq/sdk";
+
+ const bb = new Browserbase({ apiKey: process.env.BROWSERBASE_API_KEY });
+ const session = await bb.sessions.create({
+ projectId: process.env.BROWSERBASE_PROJECT_ID,
+ });
+ const browser = await browserbase.connect({
+ apiKey: process.env.BROWSERBASE_API_KEY,
+ sessionId: session.id,
+ });
+ console.log(session.id);
```
[Observability](/v4/configuration/observability) shows the full version, including reading session metrics back.
### deepLocator() folded into locator()
`page.deepLocator()` is gone, but its behavior isn't. `page.locator()` resolves the same selectors in v4, so drop the call and keep the selector:
```diff theme={null}
- const button = page.deepLocator("iframe#checkout >> button.submit");
+ const button = page.locator("iframe#checkout >> button.submit");
```
`>>` hop notation and deep XPath such as `/html/body/iframe[2]//div` work unchanged. See [Locator](/v4/reference/locator).
## Quick reference
| v3 | v4 |
| - | - |
| `new Stagehand({ env })` then `init()` | `browserbase.launch()` or `localBrowser.launch()`, then `Stagehand.create({ browser })` |
| `stagehand.page` | `await browser.context.activePage()` |
| `stagehand.context` | `browser.context` |
| `stagehand.context.pages()` | `await browser.context.pages()` |
| `page.act(...)` | `stagehand.act(...)`, with `{ page }` to target a tab |
| `await stagehand.observe(...)` returns an array | `.data` holds the array |
| `extract({ instruction, schema })` | `extract(instruction, schema)` |
| `page.deepLocator()` | `page.locator()`, same selector syntax |
| `stagehand.agent()`, `agent.execute()` | Code mode, or a tool-calling loop you own |
| `execute({ maxSteps })` | Your loop bound, or the length of the script |
| `agent({ systemPrompt })` | `systemPrompt` on `Stagehand.create()` |
| `agent({ tools })` | Page-declared tools via [WebMCP](/v4/basics/webmcp), or your own tool definitions |
| `agent({ mode: "cua" })`, `execute({ highlightCursor })` | No equivalent |
| Agent stopping when the task is done | The script ends, or your loop's own stop condition |
| Agent step-by-step action selection | The [full tool surface](#tool-calling) |
| Agent variables | `variables` on `act()` and `observe()`, see [prompting](/v4/best-practices/prompting-best-practices) |
| Agent structured output | `extract()` with a schema |
| Agent streaming, callbacks, abort signal, message continuation | No equivalent. Your loop sits between steps, so log, cancel, or persist there |
| `modelName`, `modelClientOptions` | `model: { modelName, apiKey }` |
| `serverCache` | `cache` (still on by default) |
| `enableCaching`, `cacheDir` | No equivalent; caching is server-side |
| `verbose`, `logger` | `logging: { level, format, onLog }` |
| `await stagehand.metrics` | `await stagehand.metrics()` |
| `stagehand.browserbaseSessionID` | Hold the ID from `sessions.create()` |
## Troubleshooting
**`Constructor of class 'Stagehand' is private`.** Use `await Stagehand.create({ browser })`.
**`Property 'context' does not exist on type 'Stagehand'`.** The context lives on the browser handle: `browser.context`, or `stagehand.browser.context`.
**`Property 'act' does not exist on type 'Page'`.** `act()`, `extract()`, and `observe()` are methods on the Stagehand instance now. Target a specific tab with `options.page`.
**`Property 'length' does not exist`** on an `observe()` result. Read `.data` first.
**Your generated script uses v3 APIs.** The assistant is drawing on v2 and v3 patterns in its training data. Install the rule files from [AI rules](/v4/first-steps/ai-rules).
**`Property 'deepLocator' does not exist on type 'Page'`.** Rename the call to `page.locator()`. The selector stays the same.
**A retried step repeats a side effect.** You're retrying `act()`. Retry `observe()` instead and pass the resulting action to `act()` once.
## Next steps
Set your coding assistant up to write v4 code
Perform one action, or replay an observed one
Plan actions without performing them
Cut inference out of a stable flow
# clipboard
Source: https://docs.stagehand.dev/v4/reference/clipboard
Read, write, and dispatch clipboard operations in the browser
Access `BrowserClipboard` from the browser context. Clipboard operations target the active page unless you provide another page.
## Quick start
```typescript theme={null}
const clipboard = browser.context.clipboard;
await clipboard.writeText("Hello");
```
## readText()
Read plain text from the browser clipboard.
```typescript theme={null}
const text = await browser.context.clipboard.readText();
```
Options that select a target page and configure the operation.
The page to target. Defaults to the active page.
The clipboard text.
## writeText()
Write plain text to the browser clipboard.
```typescript theme={null}
await browser.context.clipboard.writeText("Hello");
```
The plain text to write.
Options that select a target page and configure the operation.
The page to target. Defaults to the active page.
Resolves after the clipboard operation completes.
## clear()
Clear the browser clipboard.
```typescript theme={null}
await browser.context.clipboard.clear();
```
Options that select a target page and configure the operation.
The page to target. Defaults to the active page.
Resolves after the clipboard operation completes.
## paste()
Paste the clipboard contents into the active element.
```typescript theme={null}
await browser.context.clipboard.paste();
```
Options that select a target page and configure the operation.
The page to target. Defaults to the active page.
The keyboard shortcut to dispatch.
Resolves after the clipboard operation completes.
## copy()
Copy the current selection to the browser clipboard.
```typescript theme={null}
await browser.context.clipboard.copy();
```
Options that select a target page and configure the operation.
The page to target. Defaults to the active page.
Resolves after the clipboard operation completes.
## cut()
Cut the current selection to the browser clipboard.
```typescript theme={null}
await browser.context.clipboard.cut();
```
Options that select a target page and configure the operation.
The page to target. Defaults to the active page.
Resolves after the clipboard operation completes.
## Quick start
```python theme={null}
clipboard = browser.context.clipboard
await clipboard.write_text("Hello")
```
## read\_text()
Read plain text from the browser clipboard.
```python theme={null}
text = await browser.context.clipboard.read_text()
```
The page to target. Defaults to the active page.
The clipboard text.
## write\_text()
Write plain text to the browser clipboard.
```python theme={null}
await browser.context.clipboard.write_text("Hello")
```
The plain text to write.
The page to target. Defaults to the active page.
Resolves after the clipboard operation completes.
## clear()
Clear the browser clipboard.
```python theme={null}
await browser.context.clipboard.clear()
```
The page to target. Defaults to the active page.
Resolves after the clipboard operation completes.
## paste()
Paste the clipboard contents into the active element.
```python theme={null}
await browser.context.clipboard.paste()
```
The page to target. Defaults to the active page.
The keyboard shortcut used to paste.
Resolves after the clipboard operation completes.
## copy()
Copy the current selection to the browser clipboard.
```python theme={null}
await browser.context.clipboard.copy()
```
The page to target. Defaults to the active page.
Resolves after the clipboard operation completes.
## cut()
Cut the current selection to the browser clipboard.
```python theme={null}
await browser.context.clipboard.cut()
```
The page to target. Defaults to the active page.
Resolves after the clipboard operation completes.
## Quick start
```go theme={null}
clipboard := browser.Context().Clipboard()
if err := clipboard.WriteText(ctx, "Hello", nil); err != nil {
return err
}
```
Every method takes a `context.Context` first. Methods with options accept a pointer options struct; pass `nil` for defaults.
## ReadText()
Read plain text from the browser clipboard.
```go theme={null}
text, err := browser.Context().Clipboard().ReadText(ctx, nil)
if err != nil {
return err
}
fmt.Println(text)
```
Options that select a target page. Pass `nil` to use the active page.
The page to target. Defaults to the active page.
The clipboard text and any operation error.
## WriteText()
Write plain text to the browser clipboard.
```go theme={null}
if err := browser.Context().Clipboard().WriteText(ctx, "Hello", nil); err != nil {
return err
}
```
The plain text to write.
Options that select a target page. Pass `nil` to use the active page.
The page to target. Defaults to the active page.
Returns `nil` after the clipboard operation completes.
## Clear()
Clear the browser clipboard.
```go theme={null}
if err := browser.Context().Clipboard().Clear(ctx, nil); err != nil {
return err
}
```
Options that select a target page. Pass `nil` to use the active page.
The page to target. Defaults to the active page.
Returns `nil` after the clipboard operation completes.
## Paste()
Paste the clipboard contents into the active element.
```go theme={null}
if err := browser.Context().Clipboard().Paste(ctx, nil); err != nil {
return err
}
```
Options that select a target page and configure the operation. Pass `nil` for defaults.
The page to target. Defaults to the active page.
The keyboard shortcut to dispatch: `ContextClipboardPasteParamsShortcutControlOrMetaV`, `ContextClipboardPasteParamsShortcutMetaV`, or `ContextClipboardPasteParamsShortcutControlV`.
Returns `nil` after the clipboard operation completes.
## Copy()
Copy the current selection to the browser clipboard.
```go theme={null}
if err := browser.Context().Clipboard().Copy(ctx, nil); err != nil {
return err
}
```
Options that select a target page. Pass `nil` to use the active page.
The page to target. Defaults to the active page.
Returns `nil` after the clipboard operation completes.
## Cut()
Cut the current selection to the browser clipboard.
```go theme={null}
if err := browser.Context().Clipboard().Cut(ctx, nil); err != nil {
return err
}
```
Options that select a target page. Pass `nil` to use the active page.
The page to target. Defaults to the active page.
Returns `nil` after the clipboard operation completes.
# context
Source: https://docs.stagehand.dev/v4/reference/context
Manage pages, cookies, headers, policies, and clipboard access within a browser context
Use `BrowserContext` to coordinate pages and browser state that is shared across them.
## Quick start
```typescript theme={null}
const context = browser.context;
const page = await context.newPage();
```
## pages()
Return every open page in the browser context.
```typescript theme={null}
const pages = await browser.context.pages();
```
The operation result.
## newPage()
Create a new page in the browser context.
```typescript theme={null}
const page = await browser.context.newPage("https://example.com");
```
The initial URL for the new page. Omit it to create a blank page.
The operation result.
## activePage()
Return the page that is currently active.
```typescript theme={null}
const [page] = await browser.context.pages();
```
The operation result.
## setActivePage()
Make a page the active page in the browser context.
```typescript theme={null}
await browser.context.setActivePage(page);
```
The page to make active.
Resolves after the operation completes.
## close()
Close the remote browser context.
```typescript theme={null}
await browser.context.close();
```
Resolves after the operation completes.
## addInitScript()
Run a script before scripts on every newly created page.
```typescript theme={null}
await browser.context.addInitScript(() => {
window.localStorage.clear();
});
```
A function, JavaScript source string, or object with exactly one of `path` and `content`.
An optional JSON-serializable argument. Valid only when `script` is a function.
Resolves after the operation completes.
## setExtraHTTPHeaders()
Set additional HTTP headers for every page in the context.
```typescript theme={null}
await browser.context.setExtraHTTPHeaders({ "x-test": "true" });
```
HTTP header names and values.
Resolves after the operation completes.
## getDomainPolicy()
Return the context's current domain policy.
```typescript theme={null}
const policy = await browser.context.getDomainPolicy();
```
The operation result.
Domains that the context may access.
Domains that the context must reject.
## setDomainPolicy()
Set or clear the context's domain policy.
```typescript theme={null}
await browser.context.setDomainPolicy(null);
```
The policy to apply, or null/None to clear it.
Domains that the context may access.
Domains that the context must reject.
Resolves after the operation completes.
## cookies()
Return cookies, optionally filtered to one or more URLs.
```typescript theme={null}
const cookies = await browser.context.cookies("https://example.com");
```
A URL or collection of URLs used to filter cookies.
The operation result.
The cookie name.
The cookie value.
The cookie domain.
The cookie path.
The Unix expiration time in seconds, or `-1` for a session cookie.
Whether the cookie is restricted to HTTP requests.
Whether the cookie requires HTTPS.
The cookie SameSite policy: `"Strict"`, `"Lax"`, or `"None"`.
## addCookies()
Add cookies to the browser context.
```typescript theme={null}
await browser.context.addCookies([cookie]);
```
The cookies to add. Each requires `name`, `value`, and either `url` or both `domain` and `path`.
Do not combine `url` with `domain` or `path`.
The cookie domain.
`-1` for a session cookie or a non-negative Unix timestamp in seconds.
Whether the cookie is restricted to HTTP requests.
The cookie name.
The cookie path.
The cookie SameSite policy: `"Strict"`, `"Lax"`, or `"None"`. `"None"` requires a secure cookie.
Whether the cookie requires HTTPS.
The URL scope. Cannot be `about:blank` or a `data:` URL.
The cookie value.
Resolves after the operation completes.
## clearCookies()
Clear cookies that match optional name, domain, and path filters.
```typescript theme={null}
await browser.context.clearCookies({ domain: "example.com" });
```
Options that configure this operation.
A cookie domain or regular-expression filter.
A cookie name or regular-expression filter.
A cookie path or regular-expression filter.
Resolves after the operation completes.
## Quick start
```python theme={null}
context = browser.context
page = await context.new_page()
```
## pages()
Return every open page in the browser context.
```python theme={null}
pages = await browser.context.pages()
```
The operation result.
## new\_page()
Create a new page in the browser context.
```python theme={null}
page = await browser.context.new_page("https://example.com")
```
The initial URL for the new page. Omit it to create a blank page.
The operation result.
## active\_page()
Return the page that is currently active.
```python theme={null}
page = (await browser.context.pages())[0]
```
The operation result.
## set\_active\_page()
Make a page the active page in the browser context.
```python theme={null}
await browser.context.set_active_page(page)
```
The page to make active.
Resolves after the operation completes.
## close()
Close the remote browser context.
```python theme={null}
await browser.context.close()
```
Resolves after the operation completes.
## add\_init\_script()
Run a script before scripts on every newly created page.
```python theme={null}
await browser.context.add_init_script("window.localStorage.clear()")
```
JavaScript source or a path to a JavaScript file on the SDK caller's machine.
Resolves after the operation completes.
## set\_extra\_http\_headers()
Set additional HTTP headers for every page in the context.
```python theme={null}
await browser.context.set_extra_http_headers({"x-test": "true"})
```
HTTP header names and values.
Resolves after the operation completes.
## get\_domain\_policy()
Return the context's current domain policy.
```python theme={null}
policy = await browser.context.get_domain_policy()
```
The operation result.
Domains that the context may access.
Domains that the context must reject.
## set\_domain\_policy()
Set or clear the context's domain policy.
```python theme={null}
await browser.context.set_domain_policy(None)
```
The policy to apply, or null/None to clear it.
Domains that the context may access.
Domains that the context must reject.
Resolves after the operation completes.
## cookies()
Return cookies, optionally filtered to one or more URLs.
```python theme={null}
cookies = await browser.context.cookies("https://example.com")
```
A URL or collection of URLs used to filter cookies.
The operation result.
The cookie name.
The cookie value.
The cookie domain.
The cookie path.
The Unix expiration time in seconds, or `-1` for a session cookie.
Whether the cookie is restricted to HTTP requests.
Whether the cookie requires HTTPS.
The cookie SameSite policy: `"Strict"`, `"Lax"`, or `"None"`.
## add\_cookies()
Add cookies to the browser context.
```python theme={null}
await browser.context.add_cookies([cookie])
```
The cookies to add. Each requires `name`, `value`, and either `url` or both `domain` and `path`.
Do not combine `url` with `domain` or `path`.
The cookie domain.
`-1` for a session cookie or a non-negative Unix timestamp in seconds.
Whether the cookie is restricted to HTTP requests.
The cookie name.
The cookie path.
The cookie SameSite policy: `"Strict"`, `"Lax"`, or `"None"`. `"None"` requires a secure cookie.
Whether the cookie requires HTTPS.
The URL scope. Cannot be `about:blank` or a `data:` URL.
The cookie value.
Resolves after the operation completes.
## clear\_cookies()
Clear cookies that match optional name, domain, and path filters.
```python theme={null}
await browser.context.clear_cookies(domain="example.com")
```
A cookie name or regular-expression filter.
A cookie domain or regular-expression filter.
A cookie path or regular-expression filter.
Resolves after the operation completes.
## Quick start
Get the `BrowserContext` from the browser handle with `browser.Context()`. Except for the `Clipboard()` accessor, every method takes a `context.Context` as its first argument and returns an `error` as its last result.
```go theme={null}
browserContext, err := browser.Context()
if err != nil {
return err
}
page, err := browserContext.NewPage(ctx)
if err != nil {
return err
}
if _, err := page.Goto(ctx, "https://example.com", nil); err != nil {
return err
}
```
## Pages()
Return every open page in the browser context.
```go theme={null}
func (c *BrowserContext) Pages(ctx context.Context) ([]*Page, error)
```
```go theme={null}
pages, err := browserContext.Pages(ctx)
if err != nil {
return err
}
fmt.Println(len(pages))
```
The operation result.
## NewPage()
Create a new page in the browser context.
```go theme={null}
func (c *BrowserContext) NewPage(ctx context.Context, url ...string) (*Page, error)
```
```go theme={null}
if _, err := browserContext.NewPage(ctx, "https://example.com"); err != nil {
return err
}
```
The initial URL for the new page. Pass at most one URL; omit it to create a blank page.
The operation result.
## ActivePage()
Return the page that is currently active.
```go theme={null}
func (c *BrowserContext) ActivePage(ctx context.Context) (*Page, error)
```
```go theme={null}
if _, err := browserContext.ActivePage(ctx); err != nil {
return err
}
```
The operation result. Returns `nil` when no page is active.
## SetActivePage()
Make a page the active page in the browser context.
```go theme={null}
func (c *BrowserContext) SetActivePage(ctx context.Context, page *Page) error
```
```go theme={null}
if err := browserContext.SetActivePage(ctx, page); err != nil {
return err
}
```
The page to make active.
`nil` after the operation completes.
## Close()
Close the remote browser context.
```go theme={null}
func (c *BrowserContext) Close(ctx context.Context) error
```
```go theme={null}
if err := browserContext.Close(ctx); err != nil {
return err
}
```
`nil` after the operation completes.
## AddInitScript()
Run a script before scripts on every newly created page.
```go theme={null}
func (c *BrowserContext) AddInitScript(ctx context.Context, source string) error
```
```go theme={null}
if err := browserContext.AddInitScript(ctx, "window.localStorage.clear()"); err != nil {
return err
}
```
JavaScript source to evaluate. Go functions and file paths are not interpreted.
`nil` after the operation completes.
## SetExtraHTTPHeaders()
Set additional HTTP headers for every page in the context.
```go theme={null}
func (c *BrowserContext) SetExtraHTTPHeaders(ctx context.Context, headers ContextSetExtraHTTPHeadersParamsHeaders) error
```
```go theme={null}
if err := browserContext.SetExtraHTTPHeaders(ctx, map[string]string{"x-test": "true"}); err != nil {
return err
}
```
HTTP header names and values. The type is a named `map[string]string`, so a plain map literal works.
`nil` after the operation completes.
## GetDomainPolicy()
Return the context's current domain policy.
```go theme={null}
func (c *BrowserContext) GetDomainPolicy(ctx context.Context) (*DomainPolicy, error)
```
```go theme={null}
policy, err := browserContext.GetDomainPolicy(ctx)
if err != nil {
return err
}
fmt.Println(policy)
```
The operation result. Returns `nil` when no policy is set.
Domains that the context may access.
Domains that the context must reject.
## SetDomainPolicy()
Set or clear the context's domain policy.
```go theme={null}
func (c *BrowserContext) SetDomainPolicy(ctx context.Context, policy *DomainPolicy) error
```
```go theme={null}
if err := browserContext.SetDomainPolicy(ctx, nil); err != nil {
return err
}
```
The policy to apply, or `nil` to clear it.
Domains that the context may access.
Domains that the context must reject.
`nil` after the operation completes.
## Cookies()
Return cookies, optionally filtered to one or more URLs.
```go theme={null}
func (c *BrowserContext) Cookies(ctx context.Context, urls *StringList) ([]Cookie, error)
```
```go theme={null}
urls := stagehand.StringList{"https://example.com"}
cookies, err := browserContext.Cookies(ctx, &urls)
if err != nil {
return err
}
fmt.Println(cookies)
```
A collection of URLs used to filter cookies. Pass `nil` to return every cookie.
The operation result.
The cookie name.
The cookie value.
The cookie domain.
The cookie path.
The Unix expiration time in seconds, or `-1` for a session cookie.
Whether the cookie is restricted to HTTP requests.
Whether the cookie requires HTTPS.
The cookie SameSite policy: `CookieSameSiteStrict`, `CookieSameSiteLax`, or
`CookieSameSiteNone`.
## AddCookies()
Add cookies to the browser context.
```go theme={null}
func (c *BrowserContext) AddCookies(ctx context.Context, cookies []CookieParam) error
```
```go theme={null}
url := "https://example.com"
cookie := stagehand.CookieParam{Name: "session", Value: "abc123", URL: &url}
if err := browserContext.AddCookies(ctx, []stagehand.CookieParam{cookie}); err != nil {
return err
}
```
The cookies to add. Each requires `Name`, `Value`, and either `URL` or both `Domain` and `Path`.
Do not combine `URL` with `Domain` or `Path`. Optional fields are pointers.
The cookie domain.
`-1` for a session cookie or a non-negative Unix timestamp in seconds.
Whether the cookie is restricted to HTTP requests.
The cookie name.
The cookie path.
The cookie SameSite policy: `CookieParamSameSiteStrict`, `CookieParamSameSiteLax`, or
`CookieParamSameSiteNone`. `CookieParamSameSiteNone` requires a secure cookie.
Whether the cookie requires HTTPS.
The URL scope. Cannot be `about:blank` or a `data:` URL.
The cookie value.
`nil` after the operation completes.
## ClearCookies()
Clear cookies that match optional name, domain, and path filters.
```go theme={null}
func (c *BrowserContext) ClearCookies(ctx context.Context, options *ClearCookieOptions) error
```
```go theme={null}
domain := stagehand.ExactCookie("example.com")
if err := browserContext.ClearCookies(ctx, &stagehand.ClearCookieOptions{Domain: &domain}); err != nil {
return err
}
```
Options that configure this operation. Pass `nil` to clear every cookie. Build each filter with `ExactCookie()` for an exact string or `RegexCookie()` for a regular expression.
A cookie name filter.
A cookie domain filter.
A cookie path filter.
`nil` after the operation completes.
# locator
Source: https://docs.stagehand.dev/v4/reference/locator
Target elements and perform focused DOM interactions
Create a `Locator` from a page selector, then reuse it for element interactions and state queries. `first()` and `nth()` refine the local locator descriptor; the resulting descriptor is sent with later operations.
Selectors pierce shadow DOM, including closed roots.
Selectors also cross iframe boundaries. Separate a frame from a target inside it with `>>`, and Stagehand resolves each hop in turn, so `page.locator("iframe#checkout >> button.submit")` reaches the button inside that iframe. Chain as many hops as the page nests. A deep XPath that steps through an iframe resolves the same way, as in `/html/body/iframe[2]//div`.
**Closed roots require a navigated page.** Stagehand resolves elements inside iframes and shadow DOMs for you, so you rarely need to target nested documents manually. This relies on privileged browser APIs that aren't available on `about:blank` or `data:` URLs, so navigate to a `http:` or `https:` URL first when a target sits inside an iframe or a closed shadow root (see [locator limitations](/v4/reference/locator)).
## Quick start
```typescript theme={null}
const locator = page.locator("button[type=submit]");
await locator.click();
```
## click()
Click the element matched by this locator.
```typescript theme={null}
await locator.click();
```
Options that configure this operation.
The mouse button to use: `"left"`, `"middle"`, or `"right"`.
The number of clicks to dispatch.
Resolves after the operation completes.
## hover()
Move the pointer over the matched element.
```typescript theme={null}
await locator.hover();
```
Resolves after the operation completes.
## fill()
Replace the matched input's value.
```typescript theme={null}
await locator.fill("Browserbase");
```
The value to set.
Resolves after the operation completes.
## count()
Count the elements matched by this locator.
```typescript theme={null}
const count = await locator.count();
```
The operation result.
## isChecked()
Check whether the matched control is checked.
```typescript theme={null}
const checked = await locator.isChecked();
```
The operation result.
## inputValue()
Return the current value of the matched input.
```typescript theme={null}
const value = await locator.inputValue();
```
The operation result.
## isVisible()
Check whether the matched element is visible.
```typescript theme={null}
const visible = await locator.isVisible();
```
The operation result.
## innerText()
Return the rendered text inside the matched element.
```typescript theme={null}
const text = await locator.innerText();
```
The operation result.
## innerHtml()
Return the HTML inside the matched element.
```typescript theme={null}
const html = await locator.innerHtml();
```
The operation result.
## textContent()
Return the text content of the matched element.
```typescript theme={null}
const text = await locator.textContent();
```
The operation result.
## scrollTo()
Scroll the matched element to a percentage of its range.
```typescript theme={null}
await locator.scrollTo(50);
```
A percentage from 0 to 100, optionally expressed as a percentage string.
Resolves after the operation completes.
## centroid()
Return the center point of the matched element.
```typescript theme={null}
const point = await locator.centroid();
```
The operation result.
The horizontal center coordinate.
The vertical center coordinate.
## highlight()
Temporarily highlight the matched element.
```typescript theme={null}
await locator.highlight();
```
Options that configure this operation.
The highlight border color.
The alpha channel.
The blue channel.
The green channel.
The red channel.
The highlight fill color.
The alpha channel.
The blue channel.
The green channel.
The red channel.
How long to display the highlight.
Resolves after the operation completes.
## sendClickEvent()
Dispatch a DOM click event on the matched element.
```typescript theme={null}
await locator.sendClickEvent();
```
Options that configure this operation.
Whether the event bubbles.
Whether the event can be canceled.
Whether the event crosses shadow DOM boundaries.
The event-specific click detail.
Resolves after the operation completes.
## type()
Type text into the matched element.
```typescript theme={null}
await locator.type("Browserbase");
```
The text to type.
Options that configure this operation.
The input delay in milliseconds.
Resolves after the operation completes.
## selectOption()
Select one or more values in the matched select element.
```typescript theme={null}
const values = await locator.selectOption("us");
```
The value or values to select.
The operation result.
## setInputFiles()
Set one or more local files or in-memory payloads on the matched ``. The SDK resolves relative paths on the caller's machine. Pass an empty array to clear the selection.
```typescript theme={null}
await page.locator('input[type="file"]').setInputFiles("./resume.pdf");
```
Local file path(s) or in-memory payload(s) to upload. The SDK serializes each file in memory and limits it to 50 MiB.
The filename exposed to the page.
The file's MIME type.
The in-memory file content to upload. The SDK base64-encodes it for transport.
The file's last-modified time in Unix milliseconds.
Resolves after the files are attached.
## first()
Create a locator for the first matching element.
```typescript theme={null}
const first = locator.first();
```
The operation result.
## nth()
Create a locator for a matching element at a zero-based index.
```typescript theme={null}
const third = locator.nth(2);
```
The zero-based match index.
The operation result.
## Quick start
```python theme={null}
locator = page.locator("button[type=submit]")
await locator.click()
```
## click()
Click the element matched by this locator.
```python theme={null}
await locator.click()
```
The mouse button to use: `"left"`, `"middle"`, or `"right"`.
The number of clicks to dispatch.
Resolves after the operation completes.
## hover()
Move the pointer over the matched element.
```python theme={null}
await locator.hover()
```
Resolves after the operation completes.
## fill()
Replace the matched input's value.
```python theme={null}
await locator.fill("Browserbase")
```
The value to set.
Resolves after the operation completes.
## count()
Count the elements matched by this locator.
```python theme={null}
count = await locator.count()
```
The operation result.
## is\_checked()
Check whether the matched control is checked.
```python theme={null}
checked = await locator.is_checked()
```
The operation result.
## input\_value()
Return the current value of the matched input.
```python theme={null}
value = await locator.input_value()
```
The operation result.
## is\_visible()
Check whether the matched element is visible.
```python theme={null}
visible = await locator.is_visible()
```
The operation result.
## inner\_text()
Return the rendered text inside the matched element.
```python theme={null}
text = await locator.inner_text()
```
The operation result.
## inner\_html()
Return the HTML inside the matched element.
```python theme={null}
html = await locator.inner_html()
```
The operation result.
## text\_content()
Return the text content of the matched element.
```python theme={null}
text = await locator.text_content()
```
The operation result.
## scroll\_to()
Scroll the matched element to a percentage of its range.
```python theme={null}
await locator.scroll_to(50)
```
A percentage from 0 to 100, optionally expressed as a percentage string.
Resolves after the operation completes.
## centroid()
Return the center point of the matched element.
```python theme={null}
point = await locator.centroid()
```
The operation result.
The horizontal center coordinate.
The vertical center coordinate.
## highlight()
Temporarily highlight the matched element.
```python theme={null}
await locator.highlight(duration_ms=1000)
```
How long to display the highlight.
The highlight border color.
The alpha channel.
The blue channel.
The green channel.
The red channel.
The highlight fill color.
The alpha channel.
The blue channel.
The green channel.
The red channel.
Resolves after the operation completes.
## send\_click\_event()
Dispatch a DOM click event on the matched element.
```python theme={null}
await locator.send_click_event()
```
Whether the event bubbles.
Whether the event can be canceled.
Whether the event crosses shadow DOM boundaries.
Event-specific click detail.
Resolves after the operation completes.
## type()
Type text into the matched element.
```python theme={null}
await locator.type("Browserbase")
```
The text to type.
Delay between keystrokes, in milliseconds.
Resolves after the operation completes.
## select\_option()
Select one or more values in the matched select element.
```python theme={null}
values = await locator.select_option("us")
```
The value or values to select.
The operation result.
## set\_input\_files()
Set one or more local files or in-memory payloads on the matched ``. The SDK resolves relative paths on the caller's machine. Pass an empty sequence to clear the selection.
```python theme={null}
await page.locator('input[type="file"]').set_input_files("./resume.pdf")
```
Local file path(s) or in-memory payload(s) to upload. The SDK serializes each file in memory and limits it to 50 MiB.
The filename exposed to the page.
The file's MIME type.
The in-memory file content to upload. The SDK base64-encodes it for transport.
The file's last-modified time in Unix milliseconds.
Resolves after the files are attached.
## first()
Create a locator for the first matching element.
```python theme={null}
first = locator.first()
```
The operation result.
## nth()
Create a locator for a matching element at a zero-based index.
```python theme={null}
third = locator.nth(2)
```
The zero-based match index.
The operation result.
## Quick start
```go theme={null}
locator := page.Locator("button[type=submit]")
if err := locator.Click(ctx, nil); err != nil {
return err
}
```
In Go the locator type is `*stagehand.PageLocator` (the wrapper named `Locator` in the TypeScript and Python SDKs). Every method takes a `context.Context` first, and methods with options accept a pointer options struct; pass `nil` for defaults.
## Click()
Click the element matched by this locator.
```go theme={null}
if err := locator.Click(ctx, nil); err != nil {
return err
}
```
Options that configure this operation. Pass `nil` for defaults.
The mouse button to use: `MouseButtonLeft`, `MouseButtonMiddle`, or `MouseButtonRight`.
The number of clicks to dispatch.
Returns `nil` after the operation completes.
## Hover()
Move the pointer over the matched element.
```go theme={null}
if err := locator.Hover(ctx); err != nil {
return err
}
```
Returns `nil` after the operation completes.
## Fill()
Replace the matched input's value.
```go theme={null}
if err := locator.Fill(ctx, "Browserbase"); err != nil {
return err
}
```
The value to set.
Returns `nil` after the operation completes.
## Count()
Count the elements matched by this locator.
```go theme={null}
count, err := locator.Count(ctx)
if err != nil {
return err
}
fmt.Println(count)
```
The operation result.
## IsChecked()
Check whether the matched control is checked.
```go theme={null}
checked, err := locator.IsChecked(ctx)
if err != nil {
return err
}
fmt.Println(checked)
```
The operation result.
## InputValue()
Return the current value of the matched input.
```go theme={null}
value, err := locator.InputValue(ctx)
if err != nil {
return err
}
fmt.Println(value)
```
The operation result.
## IsVisible()
Check whether the matched element is visible.
```go theme={null}
visible, err := locator.IsVisible(ctx)
if err != nil {
return err
}
fmt.Println(visible)
```
The operation result.
## InnerText()
Return the rendered text inside the matched element.
```go theme={null}
text, err := locator.InnerText(ctx)
if err != nil {
return err
}
fmt.Println(text)
```
The operation result.
## InnerHTML()
Return the HTML inside the matched element.
```go theme={null}
html, err := locator.InnerHTML(ctx)
if err != nil {
return err
}
fmt.Println(html)
```
The operation result.
## TextContent()
Return the text content of the matched element.
```go theme={null}
text, err := locator.TextContent(ctx)
if err != nil {
return err
}
fmt.Println(text)
```
The operation result.
## ScrollTo()
Scroll the matched element to a percentage of its range.
```go theme={null}
if err := locator.ScrollTo(ctx, stagehand.NumericScrollPercent(50)); err != nil {
return err
}
```
A percentage from 0 to 100. Construct it with `stagehand.NumericScrollPercent(50)`, or with `stagehand.NamedScrollPercent("50%")` for a percentage string.
Returns `nil` after the operation completes.
## Centroid()
Return the center point of the matched element.
```go theme={null}
point, err := locator.Centroid(ctx)
if err != nil {
return err
}
fmt.Println(point.X, point.Y)
```
The operation result.
The horizontal center coordinate.
The vertical center coordinate.
## Highlight()
Temporarily highlight the matched element.
```go theme={null}
durationMs := 1000
err := locator.Highlight(ctx, &stagehand.LocatorHighlightOptions{DurationMs: &durationMs})
if err != nil {
return err
}
```
Options that configure this operation. Pass `nil` for defaults.
The highlight border color.
The alpha channel.
The blue channel.
The green channel.
The red channel.
The highlight fill color.
The alpha channel.
The blue channel.
The green channel.
The red channel.
How long to display the highlight.
Returns `nil` after the operation completes.
## SendClickEvent()
Dispatch a DOM click event on the matched element.
```go theme={null}
if err := locator.SendClickEvent(ctx, nil); err != nil {
return err
}
```
Options that configure this operation. Pass `nil` for defaults.
Whether the event bubbles.
Whether the event can be canceled.
Whether the event crosses shadow DOM boundaries.
The event-specific click detail.
Returns `nil` after the operation completes.
## Type()
Type text into the matched element.
```go theme={null}
if err := locator.Type(ctx, "Browserbase", nil); err != nil {
return err
}
```
The text to type.
Options that configure this operation. Pass `nil` for defaults.
The input delay in milliseconds.
Returns `nil` after the operation completes.
## SelectOption()
Select one or more values in the matched select element.
```go theme={null}
values, err := locator.SelectOption(ctx, stagehand.StringList{"us"})
if err != nil {
return err
}
fmt.Println(values)
```
The value or values to select. `StringList` is a `[]string`.
The operation result.
## SetInputFiles()
Set one or more local files or in-memory payloads on the matched ``. The SDK resolves relative paths on the caller's machine. Call it without files to clear the selection.
```go theme={null}
err := page.Locator(`input[type="file"]`).SetInputFiles(ctx, stagehand.FilePath("./resume.pdf"))
if err != nil {
return err
}
```
Local file path(s) or in-memory payload(s) to upload. Construct each with `stagehand.FilePath(path)` or `stagehand.FileData(name, mimeType, buffer)`. The SDK serializes each file in memory and limits it to 50 MiB.
A path on the SDK caller's filesystem. Cannot be combined with the payload fields.
The filename exposed to the page.
The file's MIME type.
The in-memory file content to upload. The SDK base64-encodes it for transport.
The file's last-modified time in Unix milliseconds.
Returns `nil` after the files are attached.
## First()
Create a locator for the first matching element.
```go theme={null}
first := locator.First()
if err := first.Click(ctx, nil); err != nil {
return err
}
```
The operation result.
## Nth()
Create a locator for a matching element at a zero-based index. Returns an error when the index is negative.
```go theme={null}
third, err := locator.Nth(2)
if err != nil {
return err
}
if err := third.Click(ctx, nil); err != nil {
return err
}
```
The zero-based match index.
The operation result.
# page
Source: https://docs.stagehand.dev/v4/reference/page
Navigate, inspect, and interact with a browser page
A `Page` combines deterministic browser controls with Stagehand's natural-language `act`, `observe`, and `extract` methods. Navigation methods return the main-document [`Response`](/v4/reference/response), when the navigation produces one.
## Quick start
```typescript theme={null}
const page = await browser.context.newPage();
const response = await page.goto("https://example.com");
```
## goto()
Navigate the page to a URL.
```typescript theme={null}
const response = await page.goto("https://example.com");
```
The destination URL.
Options that configure this operation.
Maximum wait time in milliseconds.
The target load state: `"load"`, `"domcontentloaded"`, or `"networkidle"`. Defaults to
`"domcontentloaded"`.
The final main-document response, or `null` when no network response was produced.
## reload()
Reload the current page.
```typescript theme={null}
await page.reload();
```
Options that configure this operation.
Whether to bypass the browser cache.
Maximum wait time in milliseconds.
The target load state: `"load"`, `"domcontentloaded"`, or `"networkidle"`. Defaults to
`"domcontentloaded"`.
The final main-document response, or `null` when no network response was produced.
## goBack()
Navigate backward in the page history.
```typescript theme={null}
await page.goBack();
```
Options that configure this operation.
Maximum wait time in milliseconds.
The target load state: `"load"`, `"domcontentloaded"`, or `"networkidle"`. Defaults to
`"domcontentloaded"`.
The final main-document response, or `null` when no history navigation occurred or no network response was produced.
## goForward()
Navigate forward in the page history.
```typescript theme={null}
await page.goForward();
```
Options that configure this operation.
Maximum wait time in milliseconds.
The target load state: `"load"`, `"domcontentloaded"`, or `"networkidle"`. Defaults to
`"domcontentloaded"`.
The final main-document response, or `null` when no history navigation occurred or no network response was produced.
## click()
Click at page coordinates.
```typescript theme={null}
await page.click(240, 320);
```
The horizontal page coordinate.
The vertical page coordinate.
Options that configure this operation.
The mouse button to use: `"left"`, `"middle"`, or `"right"`.
The number of clicks to dispatch.
Resolves after the operation completes.
## hover()
Move the pointer to page coordinates.
```typescript theme={null}
await page.hover(240, 320);
```
The horizontal page coordinate.
The vertical page coordinate.
Resolves after the operation completes.
## scroll()
Scroll from page coordinates by the supplied deltas.
```typescript theme={null}
await page.scroll(240, 320, 0, 600);
```
The horizontal page coordinate.
The vertical page coordinate.
Horizontal scroll distance.
Vertical scroll distance.
Resolves after the operation completes.
## dragAndDrop()
Drag from one page coordinate to another.
```typescript theme={null}
await page.dragAndDrop(100, 100, 400, 400);
```
The drag origin's horizontal coordinate.
The drag origin's vertical coordinate.
The drop target's horizontal coordinate.
The drop target's vertical coordinate.
Options that configure this operation.
The mouse button to use: `"left"`, `"middle"`, or `"right"`.
The input delay in milliseconds.
The number of intermediate pointer moves.
An ordered mouse route to follow instead of generating linear intermediate moves. The explicit
`toX` and `toY` coordinates remain the final point.
A route point's horizontal coordinate.
A route point's vertical coordinate.
Resolves after the operation completes.
## type()
Type text at the current input target.
```typescript theme={null}
await page.type("Browserbase");
```
The text to type.
Options that configure this operation.
The input delay in milliseconds.
Whether to simulate occasional typing mistakes.
Resolves after the operation completes.
## keyPress()
Send a keyboard key press to the page.
```typescript theme={null}
await page.keyPress("Enter");
```
The keyboard key to send.
Options that configure this operation.
The input delay in milliseconds.
Resolves after the operation completes.
## evaluate()
Evaluate JavaScript in the page and return its result.
```typescript theme={null}
const title = await page.evaluate(() => document.title);
```
JavaScript source or a function to evaluate.
An optional argument passed to the function.
The operation result.
## on()
Subscribe to console messages from this page and its page-owned sessions. Stagehand
delivers each message using the underlying `"Runtime.consoleAPICalled"` event envelope.
```typescript theme={null}
const subscription = await page.on("console", (event) => {
console.log(event.method, event.params);
});
await page.evaluate(() => console.log("ready"));
await subscription.unsubscribe();
```
The console event name. Currently, the only supported value is `"console"`.
A callback that receives the event method, raw parameters, session ID, target ID,
and page ID. Async callbacks may overlap and are not awaited by later page calls.
A subscription handle whose `unsubscribe()` method stops delivery.
### CDPSubscription
`unsubscribe(): Promise` removes the listener locally and from the Stagehand runtime.
Repeated calls share the completed cleanup. A failed runtime request can be retried by calling
`unsubscribe()` again.
## addInitScript()
Run a script before other scripts whenever the page navigates.
```typescript theme={null}
await page.addInitScript(() => {
window.localStorage.clear();
});
```
A function, JavaScript source string, or object with exactly one of `path` and `content`.
An optional JSON-serializable argument. Valid only when `script` is a function.
Resolves after the operation completes.
## setExtraHTTPHeaders()
Set additional HTTP headers for this page.
```typescript theme={null}
await page.setExtraHTTPHeaders({ "x-test": "true" });
```
HTTP header names and values.
Resolves after the operation completes.
## setViewportSize()
Set the page viewport dimensions.
```typescript theme={null}
await page.setViewportSize(1440, 900);
```
Viewport width in CSS pixels.
Viewport height in CSS pixels.
Options that configure this operation.
The device scale factor.
Resolves after the operation completes.
## waitForLoadState()
Wait until the page reaches a load state.
```typescript theme={null}
await page.waitForLoadState("networkidle", 10_000);
```
The state to wait for: `"load"`, `"domcontentloaded"`, or `"networkidle"`.
Maximum wait time in milliseconds.
Resolves after the operation completes.
## waitForTimeout()
Wait for a fixed number of milliseconds.
```typescript theme={null}
await page.waitForTimeout(500);
```
The number of milliseconds to wait.
Resolves after the operation completes.
## waitForSelector()
Wait for a selector to reach the requested state.
```typescript theme={null}
const matched = await page.waitForSelector("main");
```
The selector to target.
Options that configure this operation.
Whether to traverse shadow roots. Closed roots require a navigated page; see the [locator reference](/v4/reference/locator).
The state to wait for: `"attached"`, `"detached"`, `"visible"`, or `"hidden"`.
The operation timeout in milliseconds.
The operation result.
## screenshot()
Capture a screenshot of the page.
```typescript theme={null}
const image = await page.screenshot({ fullPage: true });
```
Options that configure this operation.
How to handle active animations: `"disabled"` or `"allow"`.
How to render the text caret: `"hide"` or `"initial"`.
The screenshot crop rectangle.
The positive height in CSS pixels.
The positive width in CSS pixels.
The horizontal coordinate.
The vertical coordinate.
Whether to capture the full scrollable page. Cannot be combined with `clip`.
Page-created locators for elements to obscure. Every locator must belong to this page.
The CSS mask color.
Whether to use a transparent background.
A file path where the image is also written.
JPEG quality as an integer from 0 to 100. Valid only when `type` is `"jpeg"`.
Whether to use `"css"` or `"device"` pixel scale.
CSS applied while taking the screenshot.
The non-negative operation timeout in milliseconds.
The image format: `"png"` or `"jpeg"`.
The screenshot image bytes.
## snapshot()
Capture the page's accessibility-oriented DOM snapshot.
```typescript theme={null}
const snapshot = await page.snapshot();
```
Options that configure this operation.
Whether to include iframe content.
The operation result.
The formatted page tree.
The snapshot URL lookup.
The snapshot XPath lookup.
## tools()
Return the WebMCP tools registered on the page.
```typescript theme={null}
const tools = await page.tools();
const searchTool = tools.find((tool) => tool.name === "search");
if (!searchTool) throw new Error("Search tool is unavailable");
console.log(searchTool.inputSchema);
const invocation = await searchTool.invoke({
input: { query: "Stagehand" },
});
const result = await invocation.result();
console.log(result.status, result.output);
```
Options that configure tool discovery.
Maximum time in milliseconds to wait for registered tools.
The callable WebMCP tools registered on the page. See the
[WebMCP reference](/v4/reference/webmcp).
## url()
Return the page's current URL.
```typescript theme={null}
const url = await page.url();
```
The operation result.
## title()
Return the page's current document title.
```typescript theme={null}
const title = await page.title();
```
The operation result.
## close()
Close the page.
```typescript theme={null}
await page.close();
```
Resolves after the operation completes.
## locator()
Create a locator for a CSS selector on this page.
```typescript theme={null}
const locator = page.locator("button[type=submit]");
```
The selector to target.
The operation result.
## Quick start
```python theme={null}
page = await browser.context.new_page()
response = await page.goto("https://example.com")
```
## goto()
Navigate the page to a URL.
```python theme={null}
response = await page.goto("https://example.com")
```
The destination URL.
The load state to wait for. Defaults to `"domcontentloaded"`.
Maximum wait time in milliseconds.
The final main-document response, or `None` when no network response was produced.
## reload()
Reload the current page.
```python theme={null}
await page.reload()
```
The load state to wait for. Defaults to `"domcontentloaded"`.
Maximum wait time in milliseconds.
Whether to bypass the browser cache.
The final main-document response, or `None` when no network response was produced.
## go\_back()
Navigate backward in the page history.
```python theme={null}
await page.go_back()
```
The load state to wait for. Defaults to `"domcontentloaded"`.
Maximum wait time in milliseconds.
The final main-document response, or `None` when no history navigation occurred or no network response was produced.
## go\_forward()
Navigate forward in the page history.
```python theme={null}
await page.go_forward()
```
The load state to wait for. Defaults to `"domcontentloaded"`.
Maximum wait time in milliseconds.
The final main-document response, or `None` when no history navigation occurred or no network response was produced.
## click()
Click at page coordinates.
```python theme={null}
await page.click(240, 320)
```
The horizontal page coordinate.
The vertical page coordinate.
The mouse button to use.
The number of clicks to dispatch.
Resolves after the operation completes.
## hover()
Move the pointer to page coordinates.
```python theme={null}
await page.hover(240, 320)
```
The horizontal page coordinate.
The vertical page coordinate.
Resolves after the operation completes.
## scroll()
Scroll from page coordinates by the supplied deltas.
```python theme={null}
await page.scroll(240, 320, 0, 600)
```
The horizontal page coordinate.
The vertical page coordinate.
Horizontal scroll distance.
Vertical scroll distance.
Resolves after the operation completes.
## drag\_and\_drop()
Drag from one page coordinate to another.
```python theme={null}
await page.drag_and_drop(100, 100, 400, 400)
```
The drag origin's horizontal coordinate.
The drag origin's vertical coordinate.
The drop target's horizontal coordinate.
The drop target's vertical coordinate.
The mouse button to use.
The number of intermediate pointer moves.
An optional input delay in milliseconds.
An ordered mouse route to follow instead of generating linear intermediate moves. The explicit
`to_x` and `to_y` coordinates remain the final point.
A route point's horizontal coordinate.
A route point's vertical coordinate.
Resolves after the operation completes.
## type()
Type text at the current input target.
```python theme={null}
await page.type("Browserbase")
```
The text to type.
An optional input delay in milliseconds.
Whether to simulate occasional typing mistakes.
Resolves after the operation completes.
## key\_press()
Send a keyboard key press to the page.
```python theme={null}
await page.key_press("Enter")
```
The keyboard key to send.
An optional input delay in milliseconds.
Resolves after the operation completes.
## evaluate()
Evaluate JavaScript in the page and return its result.
```python theme={null}
title = await page.evaluate("document.title", result_type=str)
```
JavaScript source or a function to evaluate.
An optional Python type used to validate the result.
The operation result.
## on()
Subscribe to console messages from this page and its page-owned sessions. Stagehand
delivers each message using the underlying `"Runtime.consoleAPICalled"` event envelope.
```python theme={null}
async def handle_console(event: PageCDPEvent) -> None:
print(event.method, event.params)
subscription = await page.on("console", handle_console)
await page.evaluate('console.log("ready")')
await subscription.unsubscribe()
```
The console event name. Currently, the only supported value is `"console"`.
A sync or async callback that receives the event method, raw parameters, session
ID, target ID, and page ID. Async callbacks may overlap and are not awaited by
later page calls.
A subscription handle whose `unsubscribe()` method stops delivery.
### CDPSubscription
`await subscription.unsubscribe()` removes the listener locally and from the Stagehand runtime.
Repeated calls await the same successful cleanup. Failed cleanup can be retried. If the caller is
cancelled while awaiting cleanup, the shared cleanup task continues in the background.
## add\_init\_script()
Run a script before other scripts whenever the page navigates.
```python theme={null}
await page.add_init_script("window.localStorage.clear()")
```
JavaScript source or a path to a JavaScript file on the SDK caller's machine.
Resolves after the operation completes.
## set\_extra\_http\_headers()
Set additional HTTP headers for this page.
```python theme={null}
await page.set_extra_http_headers({"x-test": "true"})
```
HTTP header names and values.
Resolves after the operation completes.
## set\_viewport\_size()
Set the page viewport dimensions.
```python theme={null}
await page.set_viewport_size(1440, 900)
```
Viewport width in CSS pixels.
Viewport height in CSS pixels.
The device scale factor.
Resolves after the operation completes.
## wait\_for\_load\_state()
Wait until the page reaches a load state.
```python theme={null}
await page.wait_for_load_state("networkidle", timeout=10_000)
```
The state to wait for: `"load"`, `"domcontentloaded"`, or `"networkidle"`.
Maximum wait time in milliseconds.
Resolves after the operation completes.
## wait\_for\_timeout()
Wait for a fixed number of milliseconds.
```python theme={null}
await page.wait_for_timeout(500)
```
The number of milliseconds to wait.
Resolves after the operation completes.
## wait\_for\_selector()
Wait for a selector to reach the requested state.
```python theme={null}
matched = await page.wait_for_selector("main", state="visible")
```
The selector to target.
The state to wait for: `"attached"`, `"detached"`, `"visible"`, or `"hidden"`.
Maximum wait time in milliseconds.
Whether to traverse shadow roots. Closed roots require a navigated page; see the [locator reference](/v4/reference/locator).
The operation result.
## screenshot()
Capture a screenshot of the page.
```python theme={null}
image = await page.screenshot(full_page=True)
```
How to handle CSS animations: `"disabled"` or `"allow"`.
How to render the text caret: `"hide"` or `"initial"`.
The region to capture.
The positive height in CSS pixels.
The positive width in CSS pixels.
The horizontal coordinate.
The vertical coordinate.
Whether to capture the full scrollable page. Cannot be combined with `clip`.
A path where the image is also written.
Page-created locators for elements to obscure. Every locator must belong to this page.
The CSS color used for masks.
Whether to use a transparent background.
JPEG quality as an integer from 0 to 100. Valid only when `type` is `"jpeg"`.
Whether to use `"css"` or `"device"` pixel scale.
CSS applied while taking the screenshot.
Maximum non-negative wait time in milliseconds.
The image format: `"png"` or `"jpeg"`.
The operation result.
## snapshot()
Capture the page's accessibility-oriented DOM snapshot.
```python theme={null}
snapshot = await page.snapshot()
```
Whether to include iframe content.
The operation result.
The formatted page tree.
The snapshot URL lookup.
The snapshot XPath lookup.
## tools()
Return the WebMCP tools registered on the page.
```python theme={null}
tools = await page.tools()
search_tool = next((tool for tool in tools if tool.name == "search"), None)
if search_tool is None:
raise RuntimeError("Search tool is unavailable")
print(search_tool.input_schema)
invocation = await search_tool.invoke(input={"query": "Stagehand"})
result = await invocation.result()
print(result.status, result.output)
```
Maximum time in milliseconds to wait for registered tools.
The callable WebMCP tools registered on the page. See the
[WebMCP reference](/v4/reference/webmcp).
## url()
Return the page's current URL.
```python theme={null}
url = await page.url()
```
The operation result.
## title()
Return the page's current document title.
```python theme={null}
title = await page.title()
```
The operation result.
## close()
Close the page.
```python theme={null}
await page.close()
```
Resolves after the operation completes.
## locator()
Create a locator for a CSS selector on this page.
```python theme={null}
locator = page.locator("button[type=submit]")
```
The selector to target.
The operation result.
## Quick start
```go theme={null}
browserContext, err := browser.Context()
if err != nil {
return err
}
page, err := browserContext.NewPage(ctx)
if err != nil {
return err
}
if _, err := page.Goto(ctx, "https://example.com", nil); err != nil {
return err
}
```
Every method that talks to the browser takes a `context.Context` first and returns an `error` last; the local `Locator()` accessor takes neither. Options are passed as pointer structs; pass `nil` for defaults.
## Goto()
Navigate the page to a URL.
```go theme={null}
if _, err := page.Goto(ctx, "https://example.com", nil); err != nil {
return err
}
```
The destination URL.
Options that configure this operation. Pass `nil` for defaults.
Maximum wait time in milliseconds.
The target load state: `LoadStateLoad`, `LoadStateDOMContentLoaded`, or
`LoadStateNetworkIdle`. Defaults to `LoadStateDOMContentLoaded`.
The final main-document response, or `nil` when no network response was produced.
## Reload()
Reload the current page.
```go theme={null}
if _, err := page.Reload(ctx, nil); err != nil {
return err
}
```
Options that configure this operation. Pass `nil` for defaults.
Whether to bypass the browser cache.
Maximum wait time in milliseconds.
The target load state: `LoadStateLoad`, `LoadStateDOMContentLoaded`, or
`LoadStateNetworkIdle`. Defaults to `LoadStateDOMContentLoaded`.
The final main-document response, or `nil` when no network response was produced.
## GoBack()
Navigate backward in the page history.
```go theme={null}
if _, err := page.GoBack(ctx, nil); err != nil {
return err
}
```
Options that configure this operation. Pass `nil` for defaults.
Maximum wait time in milliseconds.
The target load state: `LoadStateLoad`, `LoadStateDOMContentLoaded`, or
`LoadStateNetworkIdle`. Defaults to `LoadStateDOMContentLoaded`.
The final main-document response, or `nil` when no history navigation occurred or no network response was produced.
## GoForward()
Navigate forward in the page history.
```go theme={null}
if _, err := page.GoForward(ctx, nil); err != nil {
return err
}
```
Options that configure this operation. Pass `nil` for defaults.
Maximum wait time in milliseconds.
The target load state: `LoadStateLoad`, `LoadStateDOMContentLoaded`, or
`LoadStateNetworkIdle`. Defaults to `LoadStateDOMContentLoaded`.
The final main-document response, or `nil` when no history navigation occurred or no network response was produced.
## Click()
Click at page coordinates.
```go theme={null}
if err := page.Click(ctx, 240, 320, nil); err != nil {
return err
}
```
The horizontal page coordinate.
The vertical page coordinate.
Options that configure this operation. Pass `nil` for defaults.
The mouse button to use: `MouseButtonLeft`, `MouseButtonMiddle`, or `MouseButtonRight`.
The number of clicks to dispatch.
Returns `nil` after the operation completes.
## Hover()
Move the pointer to page coordinates.
```go theme={null}
if err := page.Hover(ctx, 240, 320); err != nil {
return err
}
```
The horizontal page coordinate.
The vertical page coordinate.
Returns `nil` after the operation completes.
## Scroll()
Scroll from page coordinates by the supplied deltas.
```go theme={null}
if err := page.Scroll(ctx, 240, 320, 0, 600); err != nil {
return err
}
```
The horizontal page coordinate.
The vertical page coordinate.
Horizontal scroll distance.
Vertical scroll distance.
Returns `nil` after the operation completes.
## DragAndDrop()
Drag from one page coordinate to another.
```go theme={null}
if err := page.DragAndDrop(ctx, 100, 100, 400, 400, nil); err != nil {
return err
}
```
The drag origin's horizontal coordinate.
The drag origin's vertical coordinate.
The drop target's horizontal coordinate.
The drop target's vertical coordinate.
Options that configure this operation. Pass `nil` for defaults.
The mouse button to use: `MouseButtonLeft`, `MouseButtonMiddle`, or `MouseButtonRight`.
The input delay in milliseconds.
The number of intermediate pointer moves.
An ordered mouse route to follow instead of generating linear intermediate moves. The explicit
`toX` and `toY` coordinates remain the final point.
A route point's horizontal coordinate.
A route point's vertical coordinate.
Returns `nil` after the operation completes.
## Type()
Type text at the current input target.
```go theme={null}
if err := page.Type(ctx, "Browserbase", nil); err != nil {
return err
}
```
The text to type.
Options that configure this operation. Pass `nil` for defaults.
The input delay in milliseconds.
Whether to simulate occasional typing mistakes.
Returns `nil` after the operation completes.
## KeyPress()
Send a keyboard key press to the page.
```go theme={null}
if err := page.KeyPress(ctx, "Enter", nil); err != nil {
return err
}
```
The keyboard key to send.
Options that configure this operation. Pass `nil` for defaults.
The input delay in milliseconds.
Returns `nil` after the operation completes.
## Evaluate()
Evaluate JavaScript in the page and return its result. `Evaluate` returns the raw JSON
value; use the package-level generic `EvaluateAs` to decode the result into a Go type.
```go theme={null}
title, err := stagehand.EvaluateAs[string](ctx, page, "document.title")
if err != nil {
return err
}
fmt.Println(title)
```
JavaScript source to evaluate.
The operation result as raw JSON. `EvaluateAs[T]` decodes it into `T` instead.
## On()
Subscribe to console messages from this page and its page-owned sessions. Stagehand
delivers each message using the underlying `"Runtime.consoleAPICalled"` event envelope.
```go theme={null}
subscription, err := page.On(ctx, stagehand.PageEventNameConsole, func(event stagehand.PageCDPEvent) {
fmt.Println(event.Method, event.Params)
})
if err != nil {
return err
}
if _, err := page.Evaluate(ctx, `console.log("ready")`); err != nil {
return err
}
if err := subscription.Close(ctx); err != nil {
return err
}
```
The console event name. Currently, the only supported value is `PageEventNameConsole`.
A callback that receives the event method, raw parameters, session ID, target ID,
and page ID. Listeners are not awaited by later page calls.
A subscription handle whose `Close()` method stops delivery.
### CDPSubscription
`Close(ctx context.Context) error` removes the listener locally and from the Stagehand runtime.
Repeated calls after successful cleanup return `nil`. Concurrent calls share the active cleanup;
a failed attempt can be retried.
## AddInitScript()
Run a script before other scripts whenever the page navigates.
```go theme={null}
if err := page.AddInitScript(ctx, "window.localStorage.clear()"); err != nil {
return err
}
```
JavaScript source. Go functions and file paths are not interpreted.
Returns `nil` after the operation completes.
## SetExtraHTTPHeaders()
Set additional HTTP headers for this page.
```go theme={null}
headers := stagehand.PageSetExtraHTTPHeadersParamsHeaders{"x-test": "true"}
if err := page.SetExtraHTTPHeaders(ctx, headers); err != nil {
return err
}
```
HTTP header names and values, as a `map[string]string`.
Returns `nil` after the operation completes.
## SetViewportSize()
Set the page viewport dimensions.
```go theme={null}
if err := page.SetViewportSize(ctx, 1440, 900, nil); err != nil {
return err
}
```
Viewport width in CSS pixels.
Viewport height in CSS pixels.
Options that configure this operation. Pass `nil` for defaults.
The device scale factor.
Returns `nil` after the operation completes.
## WaitForLoadState()
Wait until the page reaches a load state.
```go theme={null}
timeout := 10_000
if err := page.WaitForLoadState(ctx, stagehand.LoadStateNetworkIdle, &timeout); err != nil {
return err
}
```
The state to wait for: `LoadStateLoad`, `LoadStateDOMContentLoaded`, or `LoadStateNetworkIdle`.
Maximum wait time in milliseconds. Pass `nil` for the default.
Returns `nil` after the operation completes.
## WaitForTimeout()
Wait for a fixed number of milliseconds.
```go theme={null}
if err := page.WaitForTimeout(ctx, 500); err != nil {
return err
}
```
The number of milliseconds to wait.
Returns `nil` after the operation completes.
## WaitForSelector()
Wait for a selector to reach the requested state.
```go theme={null}
matched, err := page.WaitForSelector(ctx, "main", nil)
if err != nil {
return err
}
fmt.Println(matched)
```
The selector to target.
Options that configure this operation. Pass `nil` for defaults.
Whether to traverse shadow roots. Closed roots require a navigated page; see the [locator reference](/v4/reference/locator).
The state to wait for: `PageWaitForSelectorOptionsStateAttached`,
`PageWaitForSelectorOptionsStateDetached`, `PageWaitForSelectorOptionsStateVisible`, or
`PageWaitForSelectorOptionsStateHidden`.
The operation timeout in milliseconds.
The operation result.
## Screenshot()
Capture a screenshot of the page. There is no `path` option in Go; write the returned
bytes yourself, for example with `os.WriteFile`.
```go theme={null}
fullPage := true
image, err := page.Screenshot(ctx, &stagehand.ScreenshotOptions{FullPage: &fullPage})
if err != nil {
return err
}
if err := os.WriteFile("screenshot.png", image, 0o644); err != nil {
return err
}
```
Options that configure this operation. Pass `nil` for defaults.
How to handle active animations: `PageScreenshotOptionsAnimationsDisabled` or
`PageScreenshotOptionsAnimationsAllow`.
How to render the text caret: `PageScreenshotOptionsCaretHide` or
`PageScreenshotOptionsCaretInitial`.
The screenshot crop rectangle.
The positive height in CSS pixels.
The positive width in CSS pixels.
The horizontal coordinate.
The vertical coordinate.
Whether to capture the full scrollable page. Cannot be combined with `options.Clip`.
Page-created locators for elements to obscure in the screenshot. Create each with
`page.Locator("...")`; every mask locator must belong to the page being captured.
The CSS mask color.
Whether to use a transparent background.
JPEG quality as an integer from 0 to 100. Valid only when `options.Type` is
`PageScreenshotOptionsTypeJPEG`.
Whether to use `PageScreenshotOptionsScaleCSS` or `PageScreenshotOptionsScaleDevice`.
CSS applied while taking the screenshot.
The non-negative operation timeout in milliseconds.
The image format: `PageScreenshotOptionsTypePNG` or `PageScreenshotOptionsTypeJPEG`.
The screenshot image bytes.
## Snapshot()
Capture the page's accessibility-oriented DOM snapshot.
```go theme={null}
snapshot, err := page.Snapshot(ctx, nil)
if err != nil {
return err
}
fmt.Println(snapshot.FormattedTree)
```
Options that configure this operation. Pass `nil` for defaults.
Whether to include iframe content.
The operation result.
The formatted page tree.
The snapshot URL lookup.
The snapshot XPath lookup.
## Tools()
Return the WebMCP tools registered on the page.
```go theme={null}
tools, err := page.Tools(ctx, nil)
if err != nil {
return err
}
var searchTool *stagehand.WebMCPTool
for _, tool := range tools {
if tool.Descriptor().Name == "search" {
searchTool = tool
break
}
}
if searchTool == nil {
return errors.New("search tool is unavailable")
}
fmt.Println(searchTool.Descriptor().InputSchema)
invocation, err := searchTool.Invoke(ctx, stagehand.WebMCPInput{"query": "Stagehand"})
if err != nil {
return err
}
result, err := invocation.Result(ctx, nil)
if err != nil {
return err
}
fmt.Println(result.Status, result.Output)
```
Options that configure tool discovery. Pass `nil` for defaults.
Maximum time in milliseconds to wait for registered tools.
The callable WebMCP tools registered on the page. See the
[WebMCP reference](/v4/reference/webmcp).
## URL()
Return the page's current URL.
```go theme={null}
url, err := page.URL(ctx)
if err != nil {
return err
}
fmt.Println(url)
```
The operation result.
## Title()
Return the page's current document title.
```go theme={null}
title, err := page.Title(ctx)
if err != nil {
return err
}
fmt.Println(title)
```
The operation result.
## Close()
Close the page.
```go theme={null}
if err := page.Close(ctx); err != nil {
return err
}
```
Returns `nil` after the operation completes.
## Locator()
Create a locator for a CSS selector on this page.
```go theme={null}
locator := page.Locator("button[type=submit]")
if err := locator.Click(ctx, nil); err != nil {
return err
}
```
The selector to target.
The operation result.
# response
Source: https://docs.stagehand.dev/v4/reference/response
Inspect the main-document network response returned by page navigation
The page navigation methods for going to a URL, reloading, going back, and going forward return the final main-document response when navigation performs a network request. Redirects return the final response in the chain.
The nullable result is not an error. Navigations to URLs such as `data:` and `about:`, same-document navigations, and history operations with no matching entry can complete without a network response.
Stagehand retrieves response bodies and complete metadata lazily. Use the response while its Stagehand session is open. Response handles become invalid when the session closes, and the runtime may collect older handles after enough newer responses arrive.
## Navigation
```typescript theme={null}
const response = await page.goto("https://example.com");
if (response) {
console.log(response.status(), response.url());
console.log(await response.text());
}
```
The navigation signatures are:
```typescript theme={null}
page.goto(url, options?): Promise
page.reload(options?): Promise
page.goBack(options?): Promise
page.goForward(options?): Promise
```
Navigation now returns `Response | null`, rather than returning the `Page`. The page's internal reference and `page.url()` are still updated before the method resolves. If you do not need the response, continue to ignore the return value:
```typescript theme={null}
await page.goto("https://example.com");
```
## Immediate metadata
These methods read metadata included with the navigation result and do not make another RPC call:
```typescript theme={null}
response.url(): string
response.status(): number
response.statusText(): string
response.ok(): boolean
response.headers(): Record
response.fromServiceWorker(): boolean
```
`ok()` is `true` for status codes from 200 through 299. `headers()` returns normalized headers with lowercase names and returns a new object on every call.
## Headers and connection metadata
```typescript theme={null}
response.allHeaders(): Promise>
response.headerValue(name): Promise
response.headerValues(name): Promise
response.headersArray(): Promise
response.securityDetails(): Promise
response.serverAddr(): Promise
```
Header lookup is case-insensitive. `headerValue()` joins duplicate values with `, `, while `headerValues()` keeps them separate. `headersArray()` preserves order, casing, and duplicates. `allHeaders()` includes extra-info headers, such as `set-cookie`, when Chrome provides them.
## Body and completion
```typescript theme={null}
response.body(): Promise
response.text(): Promise
response.json(): Promise
response.finished(): Promise
```
Body access is lazy. Each SDK call requests the body through the response handle; Stagehand reuses the underlying browser body retrieval. `json()` preserves JSON parse errors. `finished()` resolves to `null` after success or an `Error` describing the loading failure.
## Navigation
```python theme={null}
response = await page.goto("https://example.com")
if response is not None:
print(response.status, response.url)
print(await response.text())
```
The navigation return types are:
```python theme={null}
await page.goto(url, ...) -> Response | None
await page.reload(...) -> Response | None
await page.go_back(...) -> Response | None
await page.go_forward(...) -> Response | None
```
Navigation now returns `Response | None`, rather than returning the `Page`. The page's internal reference and `await page.url()` are still updated before the method returns. If you do not need the response, continue to ignore the return value:
```python theme={null}
await page.goto("https://example.com")
```
## Immediate metadata
These properties read metadata included with the navigation result and do not make another RPC call:
```python theme={null}
response.url: str
response.status: int
response.status_text: str
response.ok: bool
response.headers: dict[str, str]
response.from_service_worker: bool
```
`ok` is `True` for status codes from 200 through 299. `headers` contains normalized lowercase names and returns a new dictionary on every access.
## Headers and connection metadata
```python theme={null}
await response.all_headers() -> dict[str, str]
await response.header_value(name) -> str | None
await response.header_values(name) -> list[str]
await response.headers_array() -> list[dict[str, str]]
await response.security_details() -> NavigationSecurityDetails | None
await response.server_addr() -> NavigationServerAddr | None
```
Header lookup is case-insensitive. `header_value()` joins duplicate values with `, `, while `header_values()` keeps them separate. `headers_array()` preserves order, casing, and duplicates. `all_headers()` includes extra-info headers, such as `set-cookie`, when Chrome provides them.
## Body and completion
```python theme={null}
await response.body() -> bytes
await response.text() -> str
await response.json() -> JsonValue
await response.finished() -> Exception | None
```
Body access is lazy. Each SDK call requests the body through the response handle; Stagehand reuses the underlying browser body retrieval. UTF-8 decoding and JSON parsing preserve their native errors. `finished()` returns `None` after success or an exception describing the loading failure.
## Navigation
```go theme={null}
response, err := page.Goto(ctx, "https://example.com", nil)
if err != nil {
return err
}
if response != nil {
text, err := response.Text(ctx)
if err != nil {
return err
}
fmt.Println(response.Status(), response.URL(), text)
}
```
The navigation signatures are:
```go theme={null}
func (p *Page) Goto(ctx context.Context, url string, options *PageNavigationOptions) (*Response, error)
func (p *Page) Reload(ctx context.Context, options *PageReloadOptions) (*Response, error)
func (p *Page) GoBack(ctx context.Context, options *PageNavigationOptions) (*Response, error)
func (p *Page) GoForward(ctx context.Context, options *PageNavigationOptions) (*Response, error)
```
Navigation now returns `(*Response, error)`, rather than only `error`. The page reference is updated before a successful method returns. A successful navigation without a network response returns `(nil, nil)`. Ignore the response with `_` when you only need navigation:
```go theme={null}
if _, err := page.Goto(ctx, "https://example.com", nil); err != nil {
return err
}
```
## Immediate metadata
These methods read metadata included with the navigation result and do not make another RPC call:
```go theme={null}
func (r *Response) URL() string
func (r *Response) Status() int
func (r *Response) StatusText() string
func (r *Response) OK() bool
func (r *Response) Headers() map[string]string
func (r *Response) FromServiceWorker() bool
```
`OK()` is `true` for status codes from 200 through 299. `Headers()` returns normalized headers with lowercase names and returns a new map on every call.
## Headers and connection metadata
```go theme={null}
func (r *Response) AllHeaders(ctx context.Context) (map[string]string, error)
func (r *Response) HeaderValue(ctx context.Context, name string) (string, bool, error)
func (r *Response) HeaderValues(ctx context.Context, name string) ([]string, error)
func (r *Response) HeadersArray(ctx context.Context) ([]NavigationHeader, error)
func (r *Response) SecurityDetails(ctx context.Context) (*NavigationSecurityDetails, error)
func (r *Response) ServerAddr(ctx context.Context) (*NavigationServerAddr, error)
```
Header lookup is case-insensitive. `HeaderValue()` distinguishes a missing header from a present empty value with `present`, and joins duplicate values with `, `. `HeaderValues()` keeps duplicates separate. `HeadersArray()` preserves order, casing, and duplicates. `AllHeaders()` includes extra-info headers, such as `set-cookie`, when Chrome provides them.
## Body and completion
```go theme={null}
func (r *Response) Body(ctx context.Context) ([]byte, error)
func (r *Response) Text(ctx context.Context) (string, error)
func (r *Response) JSON(ctx context.Context, destination any) error
func (r *Response) Finished(ctx context.Context) error
```
Body access is lazy. Each SDK call requests the body through the response handle; Stagehand reuses the underlying browser body retrieval. `JSON()` decodes into the supplied destination. `Finished()` returns the loading failure or an RPC error, and returns `nil` after success.
# Stagehand
Source: https://docs.stagehand.dev/v4/reference/stagehand
Initialize a browser session and run AI-powered actions, observations, and extractions
`Stagehand` attaches to a browser connection and exposes top-level lifecycle, metrics, and AI methods. The browser handle remains caller-owned.
## Quick start
```typescript theme={null}
import { localBrowser, Stagehand } from "@browserbasehq/stagehand";
const browser = await localBrowser.launch();
try {
const stagehand = await Stagehand.create({ browser });
try {
const page = await browser.context.activePage();
await page.goto("https://example.com");
} finally {
await stagehand.close();
}
} finally {
await browser.close();
}
```
## create()
Create a Stagehand instance and connect it to a browser. `Stagehand.create()` is the only way to build one; there is no public constructor, and the returned instance is already initialized.
```typescript theme={null}
const browser = await localBrowser.launch();
const stagehand = await Stagehand.create({ browser });
```
A browser handle from `browserbase.launch()`, `browserbase.connect()`, `localBrowser.launch()`, or `localBrowser.connect()`. Each handle can back only one Stagehand instance.
Browserbase API key used by managed services such as Model Gateway and server-side caching.
Stagehand API origin override for managed services. Use the service origin without `/v1`.
Default model configuration or client-provided generation callback. Browserbase selects a model automatically for Gateway sessions when omitted.
OpenTelemetry trace export configuration.
Additional system instructions included in model calls.
Whether Stagehand re-infers and retries a cached action when its recorded selector no longer resolves.
Maximum time in milliseconds to wait for the DOM to settle before an operation.
Instance-level server-side cache setting for `act()`, `observe()`, and `extract()`.
Client-side log level, output format, and optional log callback.
An initialized Stagehand instance.
## Properties
Read-only accessors on an instance.
```typescript theme={null}
const page = await stagehand.browser.context.activePage();
```
The browser handle you passed to `Stagehand.create()`. Reach pages and the context through it when you hold the instance but not the handle: `stagehand.browser.context`. Stagehand does not close this browser for you.
Whether the instance is connected and able to serve calls. This is `false` after `close()`.
## close()
Close the Stagehand runtime and release its resources. The browser handle stays open; close it separately with `browser.close()`.
```typescript theme={null}
await stagehand.close();
await browser.close();
```
Resolves after the operation completes.
## experimentalBatch()
Run a trusted, self-contained JavaScript callback inside the Stagehand extension service worker.
Operations made through the supplied batch context route directly through the worker, avoiding an
SDK-to-browser round trip for every command.
This API is experimental and can change in patch releases.
Batch callbacks execute in the Stagehand service worker, not the webpage. Treat callback source
as application code rather than untrusted input.
```typescript theme={null}
const result = await stagehand.experimentalBatch(
async (batch, input) => {
await batch.page.goto(input.url);
await batch.act("Click More information");
return {
title: await batch.page.title(),
heading: await batch.page.locator("h1").innerText(),
};
},
{ url: "https://example.com" },
);
```
### Callback arguments
Stagehand invokes the function in the service worker with two arguments: a worker-local
`ExperimentalBatchContext` and the JSON input supplied to `experimentalBatch()`.
```typescript theme={null}
await callback(
{
page: workerPage,
context: workerContext,
act: workerAct,
observe: workerObserve,
extract: workerExtract,
metrics: workerMetrics,
},
input,
);
```
The function parameter names are chosen by the caller. Destructuring is ordinary JavaScript
shorthand, not special Stagehand injection syntax:
```typescript theme={null}
async ({ page, act }, input) => {
await page.goto(input.url);
await act("Click More information");
};
// Equivalent to:
async (batch, input) => {
const page = batch.page;
const act = batch.act;
};
```
Callbacks may ignore arguments they do not need. For example,
`await stagehand.experimentalBatch(async () => "done")` is valid.
A self-contained callback executed in the extension service worker. It receives the batch
context first and `input` second. Values from the caller's lexical scope are not captured.
JSON-serializable input passed as the callback's second argument. When omitted, the callback
receives `undefined`; pass `null` explicitly to receive `null`.
Options controlling the selected page and overall deadline.
The page exposed as the callback context's `page`. The active page at batch startup is used
when omitted. This does not change the default target of `act()`, `observe()`, or `extract()`;
those methods use their own `page` option or the active page when called.
The overall callback deadline in milliseconds. Defaults to 30,000.
The callback's JSON-serializable return value.
### Batch callback context
The callback receives an `ExperimentalBatchContext`, not the outer SDK `Stagehand` instance:
* `page`: the worker-local `Page` selected when the batch starts;
* `context`: a worker-local browser context facade;
* `act`, `observe`, and `extract`: the normal Stagehand AI operations;
* `metrics`: the normal Stagehand metrics operation.
The context intentionally does not expose `context.close()`, Stagehand lifecycle methods, nested
callback batches, or page event subscriptions. Host-only behavior such as local file paths and
screenshot output paths is also unavailable because the callback runs in a browser service worker,
not Node.js.
### Passing data across the worker boundary
The callback is serialized and evaluated in a different JavaScript runtime, so it cannot capture
variables from the calling process. Pass external data through `input`, similarly to
`page.evaluate()`:
```typescript theme={null}
const selector = "button[type=submit]";
// Incorrect: selector is not defined in the service worker.
await stagehand.experimentalBatch(async ({ page }) => {
await page.locator(selector).click();
});
// Correct: input explicitly crosses the runtime boundary.
await stagehand.experimentalBatch(
async ({ page }, input) => {
await page.locator(input.selector).click();
},
{ selector },
);
```
Both `input` and the callback's return value must be JSON-serializable.
### Selecting and targeting pages
`options.page` transfers an SDK page ID to the worker. The callback receives the corresponding
worker-local page as `batch.page`; the outer `Page` object itself does not cross the boundary.
```typescript theme={null}
const [myPage] = await stagehand.browser.context.pages();
await stagehand.experimentalBatch(
async ({ page: selectedPage }, input) => {
await selectedPage.goto(input.url);
},
{ url: "https://example.com" },
{ page: myPage },
);
```
`batch.page` is fixed when the batch starts: it represents `options.page`, or the active page at
startup when no page is supplied. The AI methods follow the same targeting rules as the regular
SDK: an operation-level `page` option wins; otherwise they resolve the active page when the
operation runs. Changing the active page therefore affects later `act()`, `observe()`, and
`extract()` calls, but does not replace `batch.page`.
```typescript theme={null}
await stagehand.experimentalBatch(async (batch) => {
const nextPage = await batch.context.newPage("https://example.com/next");
await batch.context.setActivePage(nextPage);
await batch.act("Click Continue"); // Uses nextPage, the current active page.
await batch.act("Click Back", { page: batch.page }); // Explicitly uses the startup page.
});
```
Timeout cancellation prevents the callback from starting further Stagehand operations. An
operation already running when the timeout occurs may still finish, so a batch is not an atomic
transaction.
## metrics()
Return token usage and inference timing metrics for this session.
Each Stagehand instance starts at zero. Every successful operation returns `metadata.usage`; deterministic actions and cache hits report zero-valued usage, so they leave the counters unchanged. Calls that throw before returning a result are not recorded. Reading metrics does not reset them.
```typescript theme={null}
const metrics = await stagehand.metrics();
```
The operation result.
Prompt tokens used.
Completion tokens used.
Reasoning tokens used.
Cached input tokens used.
Inference time in milliseconds.
Prompt tokens used.
Completion tokens used.
Reasoning tokens used.
Cached input tokens used.
Inference time in milliseconds.
Prompt tokens used.
Completion tokens used.
Reasoning tokens used.
Cached input tokens used.
Inference time in milliseconds.
Prompt tokens used.
Completion tokens used.
Reasoning tokens used.
Cached input tokens used.
Inference time in milliseconds.
## act()
Perform an action described in natural language.
```typescript theme={null}
const result = await stagehand.act("Click the sign in button");
```
A natural-language action to perform, or an action returned by `observe()`.
The CSS selector or XPath for the action target.
A human-readable description of the action.
The action method to execute.
Arguments to pass to the action method.
Options that configure this operation.
Override server-side caching with a boolean or threshold options object.
The positive identical-result threshold required before serving a cache hit.
Page-created locators to exclude from instruction-planning context. Create each with `page.locator("...")`; use `.nth(index)` on a locator to exclude only that indexed match.
The page locator that identifies the action target. Create it with `page.locator("...")`; use `.nth(index)` on the locator to target a specific match.
Model configuration for this call. When neither this nor an initialized model exists, Browserbase selects one automatically for Gateway sessions.
The model provider API key.
Additional model provider headers.
A supported provider-prefixed [model identifier](/v4/configuration/models).
The operation timeout in milliseconds.
Variables available to the instruction.
The operation result.
The action outcome.
A summary of the completed action.
The actions that were performed.
Arguments passed to the action.
A human-readable action description.
The action method.
The action target selector.
The operation message.
Whether the action succeeded.
Metadata associated with the operation.
Cache observability for this result; status is DISABLED when no cache lookup ran.
`"HIT"` when a cached result was served, `"MISS"` when the result was computed, or
`"DISABLED"` when no cache lookup ran.
Times this cache key has been seen, including this request.
The hit-count threshold in effect for this key.
Why the cache did not serve this request; misses only.
LLM tokens avoided by serving this request from cache; hits only.
Input tokens avoided.
Output tokens avoided.
Total tokens avoided.
The action ID associated with the operation.
Aggregate LLM usage for the operation. All counters are `0` when the operation does not run inference.
Input tokens consumed by all LLM calls.
Output tokens consumed by all LLM calls.
Reasoning tokens consumed by all LLM calls.
Cached input tokens used by all LLM calls.
Total time spent waiting for LLM inference, in milliseconds.
## observe()
Find candidate actions on the page from an optional instruction.
```typescript theme={null}
const actions = await stagehand.observe("Find the sign in button");
```
The natural-language instruction.
Options that configure this operation.
Override server-side caching for this request. Requests with `locator` or `ignoreLocators` bypass server-side caching and return `metadata.cache.status` as `DISABLED`.
The identical-result threshold required before serving a cache hit.
Page-created CSS or XPath locators to exclude from consideration. Create each with `page.locator("...")`; use `.nth(index)` on a locator to exclude only that indexed match. `text=` locators are not yet supported for observe snapshot scoping.
A page-created CSS or XPath locator that scopes the operation. Create it with `page.locator("...")`; use `.nth(index)` on the locator to target a specific match. `text=` locators are not yet supported for observe snapshot scoping.
Model configuration for this call. When neither this nor an initialized model exists, Browserbase selects one automatically for Gateway sessions.
The model provider API key.
Additional model provider headers.
The model identifier.
The operation timeout in milliseconds.
Variables available to the instruction.
The operation result.
Candidate actions found on the page.
Arguments passed to the action.
A human-readable action description.
The action method.
The action target selector.
Metadata associated with the operation.
Cache observability for this result; status is DISABLED when no cache lookup ran.
Whether server-side caching served or computed this result.
Times this cache key has been seen, including this request.
The hit-count threshold in effect for this key.
Why the cache did not serve this request; misses only.
LLM tokens avoided by serving this request from cache; hits only.
Input tokens avoided.
Output tokens avoided.
Total tokens avoided.
The action ID associated with the operation.
Aggregate LLM usage for the operation. All counters are `0` when the operation does not run inference.
Input tokens consumed by all LLM calls.
Output tokens consumed by all LLM calls.
Reasoning tokens consumed by all LLM calls.
Cached input tokens used by all LLM calls.
Total time spent waiting for LLM inference, in milliseconds.
## extract()
Extract structured data from the page.
```typescript theme={null}
const product = await stagehand.extract("Extract the product", ProductSchema);
console.log(product.data, product.metadata.cache.status);
```
The natural-language instruction.
The schema used to validate extracted data. Defaults to an object with a string `extraction` field
when omitted; when selecting a custom schema generic, provide the matching runtime schema.
Options that configure this operation.
Override server-side caching for this request.
The identical-result threshold required before serving a cache hit.
Page-created CSS or XPath locators to exclude from consideration. Create each with `page.locator("...")`; use `.nth(index)` on a locator to exclude only that indexed match. `text=` locators are not yet supported for extract snapshot scoping.
A page-created CSS or XPath locator that scopes the operation. Create it with `page.locator("...")`; use `.nth(index)` on the locator to target a specific match. `text=` locators are not yet supported for extract snapshot scoping.
Model configuration for this call. When neither this nor an initialized model exists, Browserbase selects one automatically for Gateway sessions.
The model provider API key.
Additional model provider headers.
The model identifier.
Whether the operation may use a screenshot.
The operation timeout in milliseconds.
The extraction result.
Data validated against the caller's schema.
Metadata associated with the extraction.
Cache observability for this result; status is DISABLED when no cache lookup ran.
Whether server-side caching served or computed this result.
Times this cache key has been seen, including this request.
The hit-count threshold in effect for this key.
Why the cache did not serve this request; misses only.
LLM tokens avoided by serving this request from cache; hits only.
Input tokens avoided.
Output tokens avoided.
Total tokens avoided.
The action ID associated with the extraction.
Aggregate LLM usage for the operation. All counters are `0` when the operation does not run inference.
Input tokens consumed by all LLM calls.
Output tokens consumed by all LLM calls.
Reasoning tokens consumed by all LLM calls.
Cached input tokens used by all LLM calls.
Total time spent waiting for LLM inference, in milliseconds.
## Quick start
```python theme={null}
from stagehand import Stagehand, local_browser
browser = await local_browser.launch()
try:
stagehand = await Stagehand.create(browser=browser)
try:
page = await browser.context.active_page()
await page.goto("https://example.com")
finally:
await stagehand.close()
finally:
await browser.close()
```
## create()
Create a Stagehand instance and connect it to a browser. `Stagehand.create()` is the only way to build one; there is no public constructor, and the returned instance is already initialized.
```python theme={null}
browser = await local_browser.launch()
stagehand = await Stagehand.create(browser=browser)
```
A browser handle from `browserbase.launch()`, `browserbase.connect()`, `local_browser.launch()`, or `local_browser.connect()`. Each handle can back only one Stagehand instance.
Browserbase API key used by managed services such as Model Gateway and server-side caching.
Stagehand API origin override for managed services. Use the service origin without `/v1`.
Default provider-prefixed model name or client-provided generation callback. Browserbase selects a model automatically for Gateway sessions when omitted.
Provider API key for the model named by `model`. It cannot be used with a generation callback.
Custom provider headers for the model named by `model`. They cannot be used with a generation callback.
OpenTelemetry trace export configuration.
Additional system instructions included in model calls.
Whether Stagehand re-infers and retries a cached action when its recorded selector no longer resolves.
Maximum time in milliseconds to wait for the DOM to settle before an operation.
Instance-level server-side cache setting for `act()`, `observe()`, and `extract()`.
Client-side log level, output format, and optional log callback.
An initialized Stagehand instance.
## Properties
Read-only accessors on an instance.
```python theme={null}
page = await stagehand.browser.context.active_page()
```
The browser handle you passed to `Stagehand.create()`. Reach pages and the context through it when you hold the instance but not the handle: `stagehand.browser.context`. Stagehand does not close this browser for you.
Whether the instance is connected and able to serve calls. This is `False` after `close()`.
## close()
Close the Stagehand runtime and release its resources. The browser handle stays open; close it separately with `browser.close()`.
```python theme={null}
await stagehand.close()
await browser.close()
```
Resolves after the operation completes.
## experimental\_batch()
Run trusted, self-contained JavaScript source inside the Stagehand extension service worker.
Operations made through the supplied batch context route directly through the worker, avoiding a
Python-to-browser round trip for every command. Python source is not translated to JavaScript;
this method accepts an explicit JavaScript function string.
This API is experimental and can change in patch releases.
Batch callbacks execute in the Stagehand service worker, not the webpage. Treat callback source
as application code rather than untrusted input.
```python theme={null}
result = await stagehand.experimental_batch(
"""
async (batch, input) => {
await batch.page.goto(input.url);
await batch.act("Click More information");
return { title: await batch.page.title() };
}
""",
{"url": "https://example.com"},
)
```
Stagehand evaluates the function in the service worker and calls it with two JavaScript arguments:
1. a worker-local batch context containing `page`, `context`, `act`, `observe`, `extract`, and
`metrics`;
2. the decoded JSON value passed as the Python method's `input` argument.
JavaScript destructuring such as `async ({ page, act }, input) => { ... }` only creates local
variables from that context. It does not select a page or tell Stagehand which values to inject.
Self-contained JavaScript function source executed in the extension service worker. Python
variables and closures are not captured.
JSON-serializable input passed as the JavaScript callback's second argument. When omitted, the
callback receives JavaScript `undefined`; explicitly passing `None` produces JavaScript `null`.
The overall callback deadline in milliseconds. Defaults to 30,000.
The page exposed as the callback context's `page`. The active page at batch startup is used when
omitted. This does not change the default target of `act()`, `observe()`, or `extract()`; those
methods use their own `page` option or the active page when called.
The callback's decoded JSON return value.
### Batch callback context
The first JavaScript argument is a batch context, not the outer Python `Stagehand` object:
* `batch.page`: the worker-local page selected when the batch starts;
* `batch.context`: a worker-local browser context facade;
* `batch.act()`, `batch.observe()`, and `batch.extract()`: Stagehand AI operations;
* `batch.metrics()`: Stagehand metrics.
The context does not expose `context.close()`, Stagehand lifecycle methods, nested batches, or page
event subscriptions. Node.js APIs, local filesystem paths, screenshot output paths, and other
host-only behavior are unavailable in the browser service worker.
### Passing input instead of capturing variables
The JavaScript source cannot reference Python variables. Transfer values explicitly through the
method's second argument:
```python theme={null}
selector = "button[type=submit]"
result = await stagehand.experimental_batch(
"""
async (batch, input) => {
await batch.page.locator(input.selector).click();
return { title: await batch.page.title() };
}
""",
{"selector": selector},
)
```
Both input and output must be JSON-serializable.
### Selecting and targeting pages
Passing `page=my_page` transfers the Python page's ID. The service worker reconstructs it as
`batch.page`; the Python `Page` object itself is not available inside the JavaScript source.
```python theme={null}
my_page = (await stagehand.browser.context.pages())[0]
result = await stagehand.experimental_batch(
"""
async (batch, input) => {
await batch.page.goto(input.url);
return { title: await batch.page.title() };
}
""",
{"url": "https://example.com"},
page=my_page,
)
```
`batch.page` is fixed at batch startup. It represents the supplied page, or the active page at
startup when `page` is omitted. `batch.act()`, `batch.observe()`, and `batch.extract()` instead use
their own operation-level `page` option or resolve the active page when called, matching ordinary
Stagehand behavior.
Timeout cancellation prevents later Stagehand operations from starting, but an operation already
running at the deadline may still finish. Callback batches are not atomic transactions.
## metrics()
Return token usage and inference timing metrics for this session.
Each Stagehand instance starts at zero. Every successful operation returns `metadata.usage`; deterministic actions and cache hits report zero-valued usage, so they leave the counters unchanged. Calls that raise before returning a result are not recorded. Reading metrics does not reset them.
```python theme={null}
metrics = await stagehand.metrics()
```
The operation result.
Prompt tokens used.
Completion tokens used.
Reasoning tokens used.
Cached input tokens used.
Inference time in milliseconds.
Prompt tokens used.
Completion tokens used.
Reasoning tokens used.
Cached input tokens used.
Inference time in milliseconds.
Prompt tokens used.
Completion tokens used.
Reasoning tokens used.
Cached input tokens used.
Inference time in milliseconds.
Prompt tokens used.
Completion tokens used.
Reasoning tokens used.
Cached input tokens used.
Inference time in milliseconds.
## act()
Perform an action described in natural language.
```python theme={null}
result = await stagehand.act("Click the sign in button")
```
A natural-language action to perform, or an action returned by `observe()`.
The CSS selector or XPath for the action target.
A human-readable description of the action.
The action method to execute.
Arguments to pass to the action method.
The SDK page to target. Uses the active page when omitted.
Model configuration for this call. When neither this nor an initialized model exists, Browserbase selects one automatically for Gateway sessions.
The model provider API key.
Additional model provider headers.
A supported provider-prefixed [model identifier](/v4/configuration/models).
Variables available to the instruction.
Maximum wait time in milliseconds.
The page locator that identifies the action target. Create it with `page.locator("...")`; use `.nth(index)` on the locator to target a specific match.
Page-created locators to exclude from instruction-planning context. Create each with `page.locator("...")`; use `.nth(index)` on a locator to exclude only that indexed match.
Override server-side caching with a boolean or `CacheOptions`.
The positive identical-result threshold required before serving a cache hit.
The operation result.
The action outcome.
A summary of the completed action.
The actions that were performed.
Arguments passed to the action.
A human-readable action description.
The action method.
The action target selector.
The operation message.
Whether the action succeeded.
Metadata associated with the operation.
Cache observability for this result; status is DISABLED when no cache lookup ran.
`"HIT"` when a cached result was served, `"MISS"` when the result was computed, or
`"DISABLED"` when no cache lookup ran.
Times this cache key has been seen, including this request.
The hit-count threshold in effect for this key.
Why the cache did not serve this request; misses only.
LLM tokens avoided by serving this request from cache; hits only.
Input tokens avoided.
Output tokens avoided.
Total tokens avoided.
The action ID associated with the operation.
Aggregate LLM usage for the operation. All counters are `0` when the operation does not run inference.
Input tokens consumed by all LLM calls.
Output tokens consumed by all LLM calls.
Reasoning tokens consumed by all LLM calls.
Cached input tokens used by all LLM calls.
Total time spent waiting for LLM inference, in milliseconds.
## observe()
Find candidate actions on the page from an optional instruction.
```python theme={null}
actions = await stagehand.observe("Find the sign in button")
```
The natural-language instruction.
The SDK page to target. Uses the active page when omitted.
Model configuration for this call. When neither this nor an initialized model exists, Browserbase selects one automatically for Gateway sessions.
The model provider API key.
Additional model provider headers.
The model identifier.
Variables available to the instruction.
Maximum wait time in milliseconds.
A page-created CSS or XPath locator that scopes the operation. Create it with `page.locator("...")`; use `.nth(index)` on the locator to target a specific match. `text=` locators are not yet supported for observe snapshot scoping.
Page-created CSS or XPath locators to exclude from consideration. Create each with `page.locator("...")`; use `.nth(index)` on a locator to exclude only that indexed match. `text=` locators are not yet supported for observe snapshot scoping.
Override server-side caching for this request. Requests with `locator` or `ignore_locators` bypass server-side caching and return `metadata.cache.status` as `DISABLED`.
The identical-result threshold required before serving a cache hit.
The operation result.
Candidate actions found on the page.
Arguments passed to the action.
A human-readable action description.
The action method.
The action target selector.
Metadata associated with the operation.
Cache observability for this result; status is DISABLED when no cache lookup ran.
Whether server-side caching served or computed this result.
Times this cache key has been seen, including this request.
The hit-count threshold in effect for this key.
Why the cache did not serve this request; misses only.
LLM tokens avoided by serving this request from cache; hits only.
Input tokens avoided.
Output tokens avoided.
Total tokens avoided.
The action ID associated with the operation.
Aggregate LLM usage for the operation. All counters are `0` when the operation does not run inference.
Input tokens consumed by all LLM calls.
Output tokens consumed by all LLM calls.
Reasoning tokens consumed by all LLM calls.
Cached input tokens used by all LLM calls.
Total time spent waiting for LLM inference, in milliseconds.
## extract()
Extract structured data from the page.
```python theme={null}
product = await stagehand.extract("Extract the product", Product)
print(product.data, product.metadata.cache.status)
```
The natural-language instruction.
The schema used to validate extracted data. Defaults to a model with a string `extraction` field.
The SDK page to target. Uses the active page when omitted.
Model configuration for this call. When neither this nor an initialized model exists, Browserbase selects one automatically for Gateway sessions.
The model provider API key.
Additional model provider headers.
The model identifier.
Maximum wait time in milliseconds.
A page-created CSS or XPath locator that scopes the operation. Create it with `page.locator("...")`; use `.nth(index)` on the locator to target a specific match. `text=` locators are not yet supported for extract snapshot scoping.
Whether extraction may use a screenshot.
Page-created CSS or XPath locators to exclude from consideration. Create each with `page.locator("...")`; use `.nth(index)` on a locator to exclude only that indexed match. `text=` locators are not yet supported for extract snapshot scoping.
Override server-side caching for this request.
The identical-result threshold required before serving a cache hit.
The extraction result.
Data validated against the caller's Pydantic model.
Metadata associated with the extraction.
Cache observability for this result; status is DISABLED when no cache lookup ran.
Whether server-side caching served or computed this result.
Times this cache key has been seen, including this request.
The hit-count threshold in effect for this key.
Why the cache did not serve this request; misses only.
LLM tokens avoided by serving this request from cache; hits only.
Input tokens avoided.
Output tokens avoided.
Total tokens avoided.
The action ID associated with the extraction.
Aggregate LLM usage for the operation. All counters are `0` when the operation does not run inference.
Input tokens consumed by all LLM calls.
Output tokens consumed by all LLM calls.
Reasoning tokens consumed by all LLM calls.
Cached input tokens used by all LLM calls.
Total time spent waiting for LLM inference, in milliseconds.
## Quick start
```go theme={null}
package main
import (
"context"
"log"
stagehand "github.com/browserbase/stagehand/packages/sdk-go/v4"
)
func main() {
if err := run(context.Background()); err != nil {
log.Fatal(err)
}
}
func run(ctx context.Context) error {
browser, err := stagehand.LaunchLocalBrowser(ctx, nil)
if err != nil {
return err
}
defer browser.Close(ctx)
client, err := stagehand.Create(ctx, stagehand.CreateOptions{Browser: browser})
if err != nil {
return err
}
defer client.Close(ctx)
return nil
}
```
Every Stagehand call takes a `context.Context` as its first parameter and returns an `error` as its last result; cancel the context to abandon the call.
## Create()
Create a Stagehand instance and connect it to a browser. The package-level `stagehand.Create()` function is the only way to build one, and the returned instance is already initialized.
```go theme={null}
func Create(ctx context.Context, options CreateOptions) (*Stagehand, error)
```
A browser handle from `LaunchBrowserbase()`, `ConnectBrowserbase()`, `LaunchLocalBrowser()`, or `ConnectLocalBrowser()`. Each handle can back only one Stagehand instance.
Browserbase API key used by managed services such as Model Gateway and server-side caching.
Stagehand API origin override for managed services. Use the service origin without `/v1`.
Default model configuration. Browserbase selects a model automatically for Gateway sessions when omitted.
Client-provided generation callback used instead of `options.Model`.
OpenTelemetry trace export configuration.
Additional system instructions included in model calls.
Whether Stagehand re-infers and retries a cached action when its recorded selector no longer resolves.
Maximum time in milliseconds to wait for the DOM to settle before an operation.
Instance-level server-side cache setting for `Act()`, `Observe()`, and `Extract()`.
Client-side log level, output format, and optional log callback.
An initialized Stagehand instance.
## Properties
Read-only accessor methods on an instance.
```go theme={null}
browserContext, err := client.Browser().Context()
if err != nil {
return err
}
page, err := browserContext.ActivePage(ctx)
if err != nil {
return err
}
if _, err := page.Goto(ctx, "https://example.com", nil); err != nil {
return err
}
```
The browser handle you passed to `Create()`. Reach pages and the context through it when you hold the instance but not the handle: `client.Browser().Context()`. Stagehand does not close this browser for you.
Whether the instance is connected and able to serve calls. This is `false` after `Close()`.
## Close()
Close the Stagehand session and release its resources. The `Browser` handle stays open; close it separately with `browser.Close(ctx)`.
```go theme={null}
if err := client.Close(ctx); err != nil {
return err
}
```
`nil` after the operation completes. `Close()` runs once; later calls return the first result.
## ExperimentalBatch()
Run trusted, self-contained JavaScript source inside the Stagehand extension service worker.
Operations made through the supplied batch context route directly through the worker, avoiding a
Go-to-browser round trip for every command. Go source is not translated to JavaScript; this method
accepts an explicit JavaScript function string.
This API is experimental and can change in patch releases.
Batch callbacks execute in the Stagehand service worker, not the webpage. Treat callback source
as application code rather than untrusted input.
```go theme={null}
func (s *Stagehand) ExperimentalBatch(ctx context.Context, source string, input any, result any, options ExperimentalBatchOptions) error
```
```go theme={null}
var result struct {
Title string `json:"title"`
}
err := client.ExperimentalBatch(
ctx,
`async (batch, input) => {
await batch.page.goto(input.url);
await batch.act("Click More information");
return { title: await batch.page.title() };
}`,
map[string]string{"url": "https://example.com"},
&result,
stagehand.ExperimentalBatchOptions{},
)
if err != nil {
return err
}
```
Stagehand evaluates the function in the service worker and calls it with two JavaScript arguments:
1. a worker-local batch context containing `page`, `context`, `act`, `observe`, `extract`, and
`metrics`;
2. the decoded JSON value passed as the Go method's `input` argument.
JavaScript destructuring such as `async ({ page, act }, input) => { ... }` only creates local
variables from that context. It does not select a page or tell Stagehand which values to inject.
Self-contained JavaScript function source executed in the extension service worker. Go variables
are not captured.
JSON-serializable input passed as the JavaScript callback's second argument. When `nil`, the
callback receives JavaScript `undefined`.
A non-nil pointer that receives the callback's decoded JSON return value. A callback that returns
`undefined` decodes as JSON `null`.
Batch configuration. Pass the zero value `stagehand.ExperimentalBatchOptions{}` for the
defaults.
The overall callback deadline. Defaults to 30 seconds.
The page exposed as the callback context's `page`. The active page at batch startup is used when
omitted. This does not change the default target of `Act()`, `Observe()`, or `Extract()`; those
methods use their own `Page` option or the active page when called.
`nil` after the callback's return value is decoded into `result`.
### Batch callback context
The first JavaScript argument is a batch context, not the outer Go `Stagehand` client:
* `batch.page`: the worker-local page selected when the batch starts;
* `batch.context`: a worker-local browser context facade;
* `batch.act()`, `batch.observe()`, and `batch.extract()`: Stagehand AI operations;
* `batch.metrics()`: Stagehand metrics.
The context does not expose `context.close()`, Stagehand lifecycle methods, nested batches, or page
event subscriptions. Node.js APIs, local filesystem paths, screenshot output paths, and other
host-only behavior are unavailable in the browser service worker.
### Passing input instead of capturing variables
The JavaScript source cannot reference Go variables. Transfer values explicitly through the
method's `input` argument:
```go theme={null}
selector := "button[type=submit]"
var result struct {
Title string `json:"title"`
}
err := client.ExperimentalBatch(
ctx,
`async (batch, input) => {
await batch.page.locator(input.selector).click();
return { title: await batch.page.title() };
}`,
map[string]string{"selector": selector},
&result,
stagehand.ExperimentalBatchOptions{},
)
if err != nil {
return err
}
```
Both input and output must be JSON-serializable.
### Selecting and targeting pages
Passing `Page` in the options transfers the Go page's ID. The service worker reconstructs it as
`batch.page`; the Go `Page` object itself is not available inside the JavaScript source.
```go theme={null}
pages, err := browserContext.Pages(ctx)
if err != nil {
return err
}
myPage := pages[0]
var result struct {
Title string `json:"title"`
}
err = client.ExperimentalBatch(
ctx,
`async (batch, input) => {
await batch.page.goto(input.url);
return { title: await batch.page.title() };
}`,
map[string]string{"url": "https://example.com"},
&result,
stagehand.ExperimentalBatchOptions{Page: myPage},
)
if err != nil {
return err
}
```
`batch.page` is fixed at batch startup. It represents the supplied page, or the active page at
startup when `Page` is omitted. `batch.act()`, `batch.observe()`, and `batch.extract()` instead use
their own operation-level `page` option or resolve the active page when called, matching ordinary
Stagehand behavior.
Timeout cancellation prevents later Stagehand operations from starting, but an operation already
running at the deadline may still finish. Callback batches are not atomic transactions.
## Metrics()
Return token usage and inference timing metrics for this session.
Each Stagehand instance starts at zero. Every successful operation returns `Metadata.Usage`; deterministic actions and cache hits report zero-valued usage, so they leave the counters unchanged. Calls that return an error before producing a result are not recorded. Reading metrics does not reset them.
```go theme={null}
metrics, err := client.Metrics(ctx)
if err != nil {
return err
}
fmt.Println(metrics)
```
The operation result.
Prompt tokens used.
Completion tokens used.
Reasoning tokens used.
Cached input tokens used.
Inference time in milliseconds.
Prompt tokens used.
Completion tokens used.
Reasoning tokens used.
Cached input tokens used.
Inference time in milliseconds.
Prompt tokens used.
Completion tokens used.
Reasoning tokens used.
Cached input tokens used.
Inference time in milliseconds.
Prompt tokens used.
Completion tokens used.
Reasoning tokens used.
Cached input tokens used.
Inference time in milliseconds.
## Act()
Perform an action described in natural language.
```go theme={null}
result, err := client.Act(ctx, stagehand.ActInstruction("Click the sign in button"), nil)
if err != nil {
return err
}
fmt.Println(result)
```
```go theme={null}
func (s *Stagehand) Act(ctx context.Context, instruction ActInstructionValue, options *StagehandClientActOptions) (ActResult, error)
```
A natural-language action to perform, or an action returned by `Observe()`. Construct with
`stagehand.ActInstruction("...")` for natural language, or `stagehand.ObservedAction(action)`
for an observed `Action` with these fields:
The CSS selector or XPath for the action target.
A human-readable description of the action.
The action method to execute.
Arguments to pass to the action method.
Options that configure this operation. Pass `nil` for defaults.
Override server-side caching for this request. Construct with `stagehand.CacheEnabled(enabled)`
or `stagehand.CacheWithThreshold(threshold)`, where the threshold is the positive
identical-result count required before serving a cache hit.
Page-created locators to exclude from instruction-planning context. Create each with
`page.Locator("...")`; use `Nth(index)` on a locator to exclude only that indexed match.
The page locator that identifies the action target. Create it with `page.Locator("...")`; use
`Nth(index)` on the locator to target a specific match.
Model configuration for this call. When neither this nor an initialized model exists, Browserbase selects one automatically for Gateway sessions.
The model provider API key.
Additional model provider headers.
A supported provider-prefixed [model identifier](/v4/configuration/models).
The SDK page to target. Uses the active page when omitted.
The operation timeout in milliseconds.
Variables available to the instruction.
The operation result.
The action outcome.
A summary of the completed action.
The actions that were performed.
Arguments passed to the action.
A human-readable action description.
The action method.
The action target selector.
The operation message.
Whether the action succeeded.
Metadata associated with the operation.
Cache observability for this result; status is DISABLED when no cache lookup ran.
`CacheStatusHIT` when a cached result was served, `CacheStatusMISS` when the result was
computed, or `CacheStatusDISABLED` when no cache lookup ran.
Times this cache key has been seen, including this request.
The hit-count threshold in effect for this key.
Why the cache did not serve this request; misses only.
LLM tokens avoided by serving this request from cache; hits only.
Input tokens avoided.
Output tokens avoided.
Total tokens avoided.
The action ID associated with the operation.
Aggregate LLM usage for the operation. All counters are `0` when the operation does not run inference.
Input tokens consumed by all LLM calls.
Output tokens consumed by all LLM calls.
Reasoning tokens consumed by all LLM calls.
Cached input tokens used by all LLM calls.
Total time spent waiting for LLM inference, in milliseconds.
## Observe()
Find candidate actions on the page from an optional instruction.
```go theme={null}
instruction := "Find the sign in button"
actions, err := client.Observe(ctx, &instruction, nil)
if err != nil {
return err
}
fmt.Println(actions)
```
```go theme={null}
func (s *Stagehand) Observe(ctx context.Context, instruction *string, options *StagehandClientObserveOptions) (ObserveResult, error)
```
The natural-language instruction. Pass `nil` to observe without one.
Options that configure this operation. Pass `nil` for defaults.
Override server-side caching for this request. Construct with `stagehand.CacheEnabled(enabled)`
or `stagehand.CacheWithThreshold(threshold)`, where the threshold is the identical-result count
required before serving a cache hit.
Page-created CSS or XPath locators to exclude from consideration. Create each with
`page.Locator("...")`; use `Nth(index)` on a locator to exclude only that indexed match.
`text=` locators are not yet supported for observe snapshot scoping.
A page-created CSS or XPath locator that scopes the operation. Create it with
`page.Locator("...")`; use `Nth(index)` on the locator to target a specific match. `text=`
locators are not yet supported for observe snapshot scoping.
Model configuration for this call. When neither this nor an initialized model exists, Browserbase selects one automatically for Gateway sessions.
The model provider API key.
Additional model provider headers.
The model identifier.
The SDK page to target. Uses the active page when omitted.
The operation timeout in milliseconds.
Variables available to the instruction.
The operation result.
Candidate actions found on the page.
Arguments passed to the action.
A human-readable action description.
The action method.
The action target selector.
Metadata associated with the operation.
Cache observability for this result; status is DISABLED when no cache lookup ran.
Whether server-side caching served or computed this result.
Times this cache key has been seen, including this request.
The hit-count threshold in effect for this key.
Why the cache did not serve this request; misses only.
LLM tokens avoided by serving this request from cache; hits only.
Input tokens avoided.
Output tokens avoided.
Total tokens avoided.
The action ID associated with the operation.
Aggregate LLM usage for the operation. All counters are `0` when the operation does not run inference.
Input tokens consumed by all LLM calls.
Output tokens consumed by all LLM calls.
Reasoning tokens consumed by all LLM calls.
Cached input tokens used by all LLM calls.
Total time spent waiting for LLM inference, in milliseconds.
## Extract()
Extract structured data from the page. `Extract` is a package-level generic function rather than a method: the type parameter is the schema. Stagehand derives a JSON Schema from `T`, extracts matching data, and decodes the result into a `T`. Unlike TypeScript and Python, there is no default schema; supply `T` explicitly.
```go theme={null}
type Product struct {
Name string `json:"name"`
Price float64 `json:"price"`
}
product, err := stagehand.Extract[Product](ctx, client, "Extract the product", nil)
if err != nil {
return err
}
fmt.Println(product.Data, product.Metadata.Cache.Status)
```
```go theme={null}
func Extract[T any](ctx context.Context, client *Stagehand, instruction string, options *StagehandClientExtractOptions) (TypedExtractResult[T], error)
```
The Stagehand instance to extract with.
The natural-language instruction.
Options that configure this operation. Pass `nil` for defaults.
Override server-side caching for this request. Construct with `stagehand.CacheEnabled(enabled)`
or `stagehand.CacheWithThreshold(threshold)`, where the threshold is the identical-result count
required before serving a cache hit.
Page-created CSS or XPath locators to exclude from consideration. Create each with
`page.Locator("...")`; use `Nth(index)` on a locator to exclude only that indexed match.
`text=` locators are not yet supported for extract snapshot scoping.
A page-created CSS or XPath locator that scopes the operation. Create it with
`page.Locator("...")`; use `Nth(index)` on the locator to target a specific match. `text=`
locators are not yet supported for extract snapshot scoping.
Model configuration for this call. When neither this nor an initialized model exists, Browserbase selects one automatically for Gateway sessions.
The model provider API key.
Additional model provider headers.
The model identifier.
The SDK page to target. Uses the active page when omitted.
Whether the operation may use a screenshot.
The operation timeout in milliseconds.
The extraction result.
Data decoded into the caller's type.
Metadata associated with the extraction.
Cache observability for this result; status is DISABLED when no cache lookup ran.
Whether server-side caching served or computed this result.
Times this cache key has been seen, including this request.
The hit-count threshold in effect for this key.
Why the cache did not serve this request; misses only.
LLM tokens avoided by serving this request from cache; hits only.
Input tokens avoided.
Output tokens avoided.
Total tokens avoided.
The action ID associated with the extraction.
Aggregate LLM usage for the operation. All counters are `0` when the operation does not run inference.
Input tokens consumed by all LLM calls.
Output tokens consumed by all LLM calls.
Reasoning tokens consumed by all LLM calls.
Cached input tokens used by all LLM calls.
Total time spent waiting for LLM inference, in milliseconds.
# WebMCP
Source: https://docs.stagehand.dev/v4/reference/webmcp
Inspect and invoke page-provided WebMCP tools
`page.tools()` returns page-bound `WebMCPTool` objects. Invoking a tool returns a
`WebMCPInvocation`, which can wait for Chrome's terminal response or request cancellation. See the
[Page reference](/v4/reference/page) for tool discovery options.
## WebMCPTool
### Properties
| Property | Type | Description |
| - | - | - |
| `name` | `string` | Tool name used for invocation. |
| `description` | `string` | Human-readable tool description. |
| `inputSchema` | `Record \| undefined` | Optional JSON input schema. |
| `annotations` | `WebMCPAnnotation \| undefined` | Optional `readOnly`, `untrustedContent`, and `autosubmit` flags. |
| `frameId` | `string` | Frame that published the tool. |
| `backendNodeId` | `number \| undefined` | Optional non-negative backend node identifier. |
### invoke()
```typescript theme={null}
invoke(options?: WebMCPInvokeOptions): Promise
```
`options.input` is a JSON-compatible object and defaults to `{}`. The helper supplies its own page,
frame, and tool name.
## WebMCPInvocation
### Properties
| Property | Type | Description |
| - | - | - |
| `invocationId` | `string` | Stable invocation identifier. |
| `toolName` | `string` | Name of the invoked tool. |
| `frameId` | `string` | Frame handling the invocation. |
| `input` | `Record` | JSON input sent to the tool. |
### result()
```typescript theme={null}
result(options?: WebMCPResultOptions): Promise
```
`options.timeout` is an optional non-negative timeout in milliseconds. A successful terminal
response is cached on the invocation; timeout and transport failures are not cached and can be
retried.
| Response field | Type | Description |
| - | - | - |
| `invocationId` | `string` | Invocation that produced the response. |
| `status` | `"Completed" \| "Canceled" \| "Error"` | Terminal status. |
| `output` | `unknown` | Optional JSON output. |
| `errorText` | `string \| undefined` | Optional error message. |
| `exception` | `WebMCPRemoteObject \| undefined` | Optional structured exception data. |
### cancel()
```typescript theme={null}
cancel(): Promise
```
Requests cancellation from Chrome. It does not synthesize or overwrite a terminal response; call
`result()` for the authoritative final status.
## WebMCPTool
### Properties
| Property | Type | Description |
| - | - | - |
| `name` | `str` | Tool name used for invocation. |
| `description` | `str` | Human-readable tool description. |
| `input_schema` | `dict[str, JsonValue] \| None` | Optional JSON input schema. |
| `annotations` | `WebMCPAnnotation \| None` | Optional `read_only`, `untrusted_content`, and `autosubmit` flags. |
| `frame_id` | `str` | Frame that published the tool. |
| `backend_node_id` | `int \| None` | Optional non-negative backend node identifier. |
### invoke()
```python theme={null}
async def invoke(
*,
input: Mapping[str, JsonValue] | None = None,
) -> WebMCPInvocation
```
`input` defaults to an empty dictionary. The helper supplies its own page, frame, and tool name.
## WebMCPInvocation
### Properties
| Property | Type | Description |
| - | - | - |
| `invocation_id` | `str` | Stable invocation identifier. |
| `tool_name` | `str` | Name of the invoked tool. |
| `frame_id` | `str` | Frame handling the invocation. |
| `input` | `dict[str, JsonValue]` | JSON input sent to the tool. |
### result()
```python theme={null}
async def result(*, timeout: float | None = None) -> WebMCPToolResponse
```
`timeout` is an optional non-negative timeout in milliseconds. A successful terminal response is
cached; timeout and transport failures are not cached and can be retried.
| Response field | Type | Description |
| - | - | - |
| `invocation_id` | `str` | Invocation that produced the response. |
| `status` | `Literal["Completed", "Canceled", "Error"]` | Terminal status. |
| `output` | `JsonValue \| None` | Optional JSON output. |
| `error_text` | `str \| None` | Optional error message. |
| `exception` | `dict[str, JsonValue] \| None` | Optional structured exception data. |
### cancel()
```python theme={null}
async def cancel() -> None
```
Requests cancellation from Chrome. It does not synthesize or overwrite a terminal response; call
`result()` for the authoritative final status.
## WebMCPTool
Go exposes descriptor data through a method rather than properties.
### Descriptor()
```go theme={null}
func (t *WebMCPTool) Descriptor() WebMCPToolDescriptor
```
The returned descriptor contains `Name`, `Description`, `InputSchema`, `Annotations`, `FrameID`,
and optional `BackendNodeID`. `Annotations` may contain `ReadOnly`, `UntrustedContent`, and
`Autosubmit` pointers.
### Invoke()
```go theme={null}
func (t *WebMCPTool) Invoke(
ctx context.Context,
input WebMCPInput,
) (*WebMCPInvocation, error)
```
`WebMCPInput` is a `map[string]any`; `nil` is sent as an empty JSON object. Values must be JSON
encodable. The helper supplies its own page, frame, and tool name.
## WebMCPInvocation
### Descriptor()
```go theme={null}
func (i *WebMCPInvocation) Descriptor() WebMCPInvocationDescriptor
```
The descriptor contains `InvocationID`, `ToolName`, `FrameID`, and the JSON `Input` map.
### Result()
```go theme={null}
func (i *WebMCPInvocation) Result(
ctx context.Context,
options *WebMCPResultOptions,
) (WebMCPToolResponse, error)
```
Pass `nil` for default waiting behavior. `options.Timeout` is an optional non-negative timeout in
milliseconds. A successful terminal response is cached; context and RPC failures are not cached
and can be retried.
| Response field | Type | Description |
| - | - | - |
| `InvocationID` | `string` | Invocation that produced the response. |
| `Status` | `WebMCPInvocationStatus` | `WebMCPInvocationStatusCompleted`, `WebMCPInvocationStatusCanceled`, or `WebMCPInvocationStatusError`. |
| `Output` | `json.RawMessage` | Optional JSON output. |
| `ErrorText` | `*string` | Optional error message. |
| `Exception` | `WebMCPRemoteObject` | Optional structured exception data. |
Use `WebMCPOutputAs[T](response)` to decode a non-empty JSON output into a caller-selected type.
### Cancel()
```go theme={null}
func (i *WebMCPInvocation) Cancel(ctx context.Context) error
```
Requests cancellation from Chrome. It does not synthesize or overwrite a terminal response; call
`Result()` for the authoritative final status.