From 0bd7c2c97fbff414d19ccff269b590ccf5e86db3 Mon Sep 17 00:00:00 2001 From: Alyssa Maruyama Date: Wed, 5 Aug 2026 16:24:20 -0700 Subject: [PATCH 1/9] Rough draft for v3-->v4 migration guide --- packages/docs/docs.json | 4 + packages/docs/v4/migrations/v3.mdx | 423 +++++++++++++++++++++++++++++ 2 files changed, 427 insertions(+) create mode 100644 packages/docs/v4/migrations/v3.mdx diff --git a/packages/docs/docs.json b/packages/docs/docs.json index d20f00ddf..c366bb65d 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -63,6 +63,10 @@ "v4/best-practices/mcp-integrations" ] }, + { + "group": "Migration guides", + "pages": ["v4/migrations/v3"] + }, { "group": "SDK reference", "pages": [ diff --git a/packages/docs/v4/migrations/v3.mdx b/packages/docs/v4/migrations/v3.mdx new file mode 100644 index 000000000..00396b246 --- /dev/null +++ b/packages/docs/v4/migrations/v3.mdx @@ -0,0 +1,423 @@ +--- +title: Migrate TypeScript v3 to v4 +sidebarTitle: Migrate v3 to v4 +icon: 'arrow-up-right-dots' +--- + +v4 removes `agent()` and reshapes how you construct Stagehand, reach pages, and read results. The rest of the surface is recognizable: `act()`, `extract()`, and `observe()` still take natural-language instructions and still do the same jobs. + +Start with the section below. Replacing `agent()` is the only change that asks you to write new logic rather than rename something, so it is the one worth understanding before you touch anything else. + +## Replace agent() with your own loop + +### What agent() did + +In v3 you handed Stagehand a goal and it decided the steps: + +```diff +- const agent = stagehand.agent({ +- mode: "cua", +- model: "anthropic/claude-sonnet-4-6", +- systemPrompt: "You are a helpful assistant...", +- }); +- +- await agent.execute({ +- instruction: "Find the most controversial post from today and summarize the debate", +- maxSteps: 20, +- }); +``` + +`agent()` owned the loop: it looked at the page, chose an action, ran it, looked again, and stopped when it judged the task complete or hit `maxSteps`. v4 has no equivalent call. Stagehand is the SDK for browser agents, so you should own that loop now. + +### The three jobs the loop has to do + +Any replacement needs the same three moves `agent()` made internally: + +1. **Decide whether you are done:** `extract()` reads the page and never touches it, so it is safe to call on every pass. +2. **Choose one next action:** `observe()` proposes actions without performing them. It returns `Action` objects, each with a `selector` and a `description`. +3. **Perform exactly one action:** Pass an `Action` straight to `act()`. Replaying an observed action skips inference entirely. + +### A working loop + +This is an example replacement for a single `agent.execute()` call. It returns the same three things v3's `AgentResult` gave you: whether it succeeded, a message, and how many steps it took. + + +```typescript +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod/v4"; + +const goalSchema = z.object({ + status: z.enum(["done", "not_done"]), + reason: z.string(), +}); + +async function runGoal(stagehand: Stagehand, goal: string, maxSteps = 20) { + for (let step = 0; step < maxSteps; step += 1) { + // 1. extract() only reads, so asking on every pass is safe + const { data: check } = await stagehand.extract( + `Has this goal been accomplished: ${goal}? Answer done or not_done, and say why.`, + goalSchema, + ); + if (check.status === "done") { + return { success: true, message: check.reason, steps: step }; + } + + // 2. observe() proposes without touching the page + const { data: candidates } = await stagehand.observe( + `What is the single next action that makes progress toward: ${goal}`, + ); + if (candidates.length === 0) { + return { success: false, message: "No action found on this page", steps: step }; + } + + // 3. Replaying an observed Action costs no inference + const result = await stagehand.act(candidates[0]); + if (!result.data.success) { + return { success: false, message: result.data.message, steps: step }; + } + } + + return { success: false, message: `Gave up after ${maxSteps} steps`, steps: maxSteps }; +} + +const browser = await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }); +const stagehand = await Stagehand.create({ browser }); + +try { + const page = await browser.context.activePage(); + await page.goto("https://news.ycombinator.com"); + + const outcome = await runGoal(stagehand, "open the top story's comments"); + console.log(outcome); +} finally { + await stagehand.close(); + await browser.close(); +} +``` + + + +```python +import os +from typing import Literal + +from pydantic import BaseModel + +from stagehand import Stagehand, browserbase + + +class GoalCheck(BaseModel): + status: Literal["done", "not_done"] + reason: str + + +async def run_goal(stagehand: Stagehand, goal: str, max_steps: int = 20): + for step in range(max_steps): + # 1. extract() only reads, so asking on every pass is safe + check = (await stagehand.extract( + f"Has this goal been accomplished: {goal}? Answer done or not_done, and say why.", + GoalCheck, + )).data + if check.status == "done": + return {"success": True, "message": check.reason, "steps": step} + + # 2. observe() proposes without touching the page + candidates = (await stagehand.observe( + f"What is the single next action that makes progress toward: {goal}" + )).data + if not candidates: + return {"success": False, "message": "No action found on this page", "steps": step} + + # 3. Replaying an observed Action costs no inference + result = await stagehand.act(candidates[0]) + if not result.data.success: + return {"success": False, "message": result.data.message, "steps": step} + + return {"success": False, "message": f"Gave up after {max_steps} steps", "steps": max_steps} + + +browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"]) +stagehand = await Stagehand.create(browser=browser) + +try: + page = await browser.context.active_page() + await page.goto("https://news.ycombinator.com") + + outcome = await run_goal(stagehand, "open the top story's comments") + print(outcome) +finally: + await stagehand.close() + await browser.close() +``` + + + +```go +type goalCheck struct { + Status string `json:"status"` + Reason string `json:"reason"` +} + +var goalCheckSchema = json.RawMessage(`{ + "type": "object", + "properties": { + "status": {"type": "string", "enum": ["done", "not_done"]}, + "reason": {"type": "string"} + }, + "required": ["status", "reason"], + "additionalProperties": false +}`) + +type goalOutcome struct { + Success bool + Message string + Steps int +} + +func runGoal( + ctx context.Context, + client *stagehand.Stagehand, + goal string, + maxSteps int, +) (goalOutcome, error) { + for step := 0; step < maxSteps; step++ { + // 1. Extract only reads, so asking on every pass is safe + check, err := stagehand.ExtractAs[goalCheck]( + ctx, + client, + fmt.Sprintf("Has this goal been accomplished: %s? Answer done or not_done, and say why.", goal), + goalCheckSchema, + nil, + ) + if err != nil { + return goalOutcome{}, err + } + if check.Data.Status == "done" { + return goalOutcome{Success: true, Message: check.Data.Reason, Steps: step}, nil + } + + // 2. Observe proposes without touching the page + instruction := fmt.Sprintf("What is the single next action that makes progress toward: %s", goal) + observed, err := client.Observe(ctx, &instruction, nil) + if err != nil { + return goalOutcome{}, err + } + if len(observed.Data) == 0 { + return goalOutcome{Message: "No action found on this page", Steps: step}, nil + } + + // 3. Replaying an observed Action costs no inference + result, err := client.Act(ctx, stagehand.ObservedAction(observed.Data[0]), nil) + if err != nil { + return goalOutcome{}, err + } + if !result.Data.Success { + return goalOutcome{Message: result.Data.Message, Steps: step}, nil + } + } + + return goalOutcome{Message: fmt.Sprintf("Gave up after %d steps", maxSteps), Steps: maxSteps}, 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) shows the same idea applied to model escalation. + + +### Feature mapping + +| v3 `agent()` feature | v4 | +| --- | --- | +| `agent.execute(instruction)` | The loop above | +| `maxSteps` | Your loop bound | +| Stopping when the task is done | An `extract()` goal check each pass | +| Step-by-step action selection | `observe()`, one action per pass | +| `systemPrompt` | `systemPrompt` on `Stagehand.create()` | +| Variables | `variables` on `act()` and `observe()`, see [prompting](/v4/best-practices/prompting-best-practices) | +| Structured output | `extract()` with a schema | +| MCP integrations | [MCP server](/v4/integrations/mcp/introduction) | +| Custom tools | Page-declared tools via [WebMCP](/v4/basics/webmcp), or wire the primitives into a framework agent | +| Computer Use mode, `highlightCursor` | No equivalent | +| Streaming, callbacks, abort signal, message continuation | No equivalent. Your loop already sits between steps, so log, cancel, or persist there | + + +If you want a framework to own the loop instead of writing it yourself, the [LangChain](/v4/integrations/langchain/introduction) and [CrewAI](/v4/integrations/crew-ai/introduction) integrations expose the primitives as agent tools. + + +## 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 the loop above. +5. Turn on server-side caching once the flow is stable. + +## Breaking changes + +### Initialization + +The constructor is private and `init()` is gone. Get a browser from a factory, then hand it to `Stagehand.create()`: + +```diff +- 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 is 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 +- 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`. + +### 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 +- 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 +- 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 +- 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 + +`enableCaching` is gone. v4 caches `act()`, `observe()`, and `extract()` results on Browserbase's servers instead, keyed on the instruction, page content, and options: + +```diff +- const stagehand = new Stagehand({ enableCaching: true }); ++ const stagehand = await Stagehand.create({ browser, cache: true }); +``` + +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 +- 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 +- 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 +- const stagehand = new Stagehand({ env: "BROWSERBASE" }); +- await stagehand.init(); +- console.log(stagehand.browserbaseSessionID); ++ 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. + +## 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()` | +| `stagehand.agent()` | Your own loop | +| `await stagehand.observe(...)` returns an array | `.data` holds the array | +| `extract({ instruction, schema })` | `extract(instruction, schema)` | +| `modelName`, `modelClientOptions` | `model: { modelName, apiKey }` | +| `enableCaching` | `cache` | +| `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 'length' does not exist`** on an `observe()` result. Read `.data` first. + +**Your loop never stops.** The goal check is too permissive. Ask for a strict enum, as the example does, and keep the step cap. + +**A retried step repeats a side effect.** You are retrying `act()`. Retry `observe()` instead and pass the resulting action to `act()` once. + +## Next steps + + + + Perform one action, or replay an observed one + + + Plan actions without performing them + + + Sequence multi-step work you own + + + Cut inference out of a stable flow + + From 32b9c8930120d32541331cdb2aa67fa08110d68f Mon Sep 17 00:00:00 2001 From: Alyssa Maruyama Date: Thu, 6 Aug 2026 18:40:08 -0700 Subject: [PATCH 2/9] general doc not just typescript --- packages/docs/v4/migrations/v3.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/v4/migrations/v3.mdx b/packages/docs/v4/migrations/v3.mdx index 00396b246..426fb324c 100644 --- a/packages/docs/v4/migrations/v3.mdx +++ b/packages/docs/v4/migrations/v3.mdx @@ -1,5 +1,5 @@ --- -title: Migrate TypeScript v3 to v4 +title: Migrate v3 to v4 sidebarTitle: Migrate v3 to v4 icon: 'arrow-up-right-dots' --- From 7c1e454308bd7340d4b8362aa7bc104ffb870b04 Mon Sep 17 00:00:00 2001 From: Alyssa Maruyama Date: Fri, 7 Aug 2026 01:13:54 -0500 Subject: [PATCH 3/9] next iteration of migration doc --- packages/docs/v4/migrations/v3.mdx | 414 +++++++++++++++++------------ 1 file changed, 239 insertions(+), 175 deletions(-) diff --git a/packages/docs/v4/migrations/v3.mdx b/packages/docs/v4/migrations/v3.mdx index 426fb324c..a6cc10476 100644 --- a/packages/docs/v4/migrations/v3.mdx +++ b/packages/docs/v4/migrations/v3.mdx @@ -4,91 +4,71 @@ sidebarTitle: Migrate v3 to v4 icon: 'arrow-up-right-dots' --- -v4 removes `agent()` and reshapes how you construct Stagehand, reach pages, and read results. The rest of the surface is recognizable: `act()`, `extract()`, and `observe()` still take natural-language instructions and still do the same jobs. +Two changes account for most of this migration: -Start with the section below. Replacing `agent()` is the only change that asks you to write new logic rather than rename something, so it is the one worth understanding before you touch anything else. +1. **`agent()` is gone.** Nothing in v4 replaces it one-for-one. Read this part first. +2. **The SDK surface moved.** Construction, page access, and result shapes changed. Those are mechanical renames. -## Replace agent() with your own loop +`act()`, `extract()`, and `observe()` still exist and still take natural-language instructions. Their role changed. -### What agent() did +## Why agent() is gone -In v3 you handed Stagehand a goal and it decided the steps: +`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. -```diff -- const agent = stagehand.agent({ -- mode: "cua", -- model: "anthropic/claude-sonnet-4-6", -- systemPrompt: "You are a helpful assistant...", -- }); -- -- await agent.execute({ -- instruction: "Find the most controversial post from today and summarize the debate", -- maxSteps: 20, -- }); -``` +Now, models are strong enough to plan against a real API. 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. + +v4 has two approaches that replace `agent()`. -`agent()` owned the loop: it looked at the page, chose an action, ran it, looked again, and stopped when it judged the task complete or hit `maxSteps`. v4 has no equivalent call. Stagehand is the SDK for browser agents, so you should own that loop now. +## Code mode -### The three jobs the loop has to do +Browserbase recommends this path. 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. -Any replacement needs the same three moves `agent()` made internally: +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. -1. **Decide whether you are done:** `extract()` reads the page and never touches it, so it is safe to call on every pass. -2. **Choose one next action:** `observe()` proposes actions without performing them. It returns `Action` objects, each with a `selector` and a `description`. -3. **Perform exactly one action:** Pass an `Action` straight to `act()`. Replaying an observed action skips inference entirely. +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. -### A working loop +Here's a prompt that produces a working script: -This is an example replacement for a single `agent.execute()` call. It returns the same three things v3's `AgentResult` gave you: whether it succeeded, a message, and how many steps it took. +```text +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 import { browserbase, Stagehand } from "@browserbasehq/stagehand"; import { z } from "zod/v4"; -const goalSchema = z.object({ - status: z.enum(["done", "not_done"]), - reason: z.string(), +const commentSchema = z.object({ + comments: z.array(z.object({ author: z.string(), body: z.string() })), }); -async function runGoal(stagehand: Stagehand, goal: string, maxSteps = 20) { - for (let step = 0; step < maxSteps; step += 1) { - // 1. extract() only reads, so asking on every pass is safe - const { data: check } = await stagehand.extract( - `Has this goal been accomplished: ${goal}? Answer done or not_done, and say why.`, - goalSchema, - ); - if (check.status === "done") { - return { success: true, message: check.reason, steps: step }; - } - - // 2. observe() proposes without touching the page - const { data: candidates } = await stagehand.observe( - `What is the single next action that makes progress toward: ${goal}`, - ); - if (candidates.length === 0) { - return { success: false, message: "No action found on this page", steps: step }; - } - - // 3. Replaying an observed Action costs no inference - const result = await stagehand.act(candidates[0]); - if (!result.data.success) { - return { success: false, message: result.data.message, steps: step }; - } - } - - return { success: false, message: `Gave up after ${maxSteps} steps`, steps: maxSteps }; -} - const browser = await browserbase.launch({ apiKey: process.env.BROWSERBASE_API_KEY }); const stagehand = await Stagehand.create({ browser }); try { - const page = await browser.context.activePage(); - await page.goto("https://news.ycombinator.com"); + const page = await browser.context.newPage("https://news.ycombinator.com"); - const outcome = await runGoal(stagehand, "open the top story's comments"); - console.log(outcome); + // 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(); @@ -99,158 +79,215 @@ try { ```python import os -from typing import Literal from pydantic import BaseModel - from stagehand import Stagehand, browserbase -class GoalCheck(BaseModel): - status: Literal["done", "not_done"] - reason: str - - -async def run_goal(stagehand: Stagehand, goal: str, max_steps: int = 20): - for step in range(max_steps): - # 1. extract() only reads, so asking on every pass is safe - check = (await stagehand.extract( - f"Has this goal been accomplished: {goal}? Answer done or not_done, and say why.", - GoalCheck, - )).data - if check.status == "done": - return {"success": True, "message": check.reason, "steps": step} +class Comment(BaseModel): + author: str + body: str - # 2. observe() proposes without touching the page - candidates = (await stagehand.observe( - f"What is the single next action that makes progress toward: {goal}" - )).data - if not candidates: - return {"success": False, "message": "No action found on this page", "steps": step} - # 3. Replaying an observed Action costs no inference - result = await stagehand.act(candidates[0]) - if not result.data.success: - return {"success": False, "message": result.data.message, "steps": step} - - return {"success": False, "message": f"Gave up after {max_steps} steps", "steps": max_steps} +class Comments(BaseModel): + comments: list[Comment] browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"]) stagehand = await Stagehand.create(browser=browser) try: - page = await browser.context.active_page() - await page.goto("https://news.ycombinator.com") + 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") - outcome = await run_goal(stagehand, "open the top story's comments") - print(outcome) + # 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() ``` - -```go -type goalCheck struct { - Status string `json:"status"` - Reason string `json:"reason"` -} +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. -var goalCheckSchema = json.RawMessage(`{ - "type": "object", - "properties": { - "status": {"type": "string", "enum": ["done", "not_done"]}, - "reason": {"type": "string"} +## Tool calling with the full API + +To keep a model in the loop at runtime, give it the whole Stagehand surface. Models handle toolsets this size, and precise tools are easier to plan against than three broad ones. + +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 +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(); + }, }, - "required": ["status", "reason"], - "additionalProperties": false -}`) - -type goalOutcome struct { - Success bool - Message string - Steps int -} + 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, + }, +}; +``` + -func runGoal( - ctx context.Context, - client *stagehand.Stagehand, - goal string, - maxSteps int, -) (goalOutcome, error) { - for step := 0; step < maxSteps; step++ { - // 1. Extract only reads, so asking on every pass is safe - check, err := stagehand.ExtractAs[goalCheck]( - ctx, - client, - fmt.Sprintf("Has this goal been accomplished: %s? Answer done or not_done, and say why.", goal), - goalCheckSchema, - nil, - ) - if err != nil { - return goalOutcome{}, err - } - if check.Data.Status == "done" { - return goalOutcome{Success: true, Message: check.Data.Reason, Steps: step}, nil - } - - // 2. Observe proposes without touching the page - instruction := fmt.Sprintf("What is the single next action that makes progress toward: %s", goal) - observed, err := client.Observe(ctx, &instruction, nil) - if err != nil { - return goalOutcome{}, err - } - if len(observed.Data) == 0 { - return goalOutcome{Message: "No action found on this page", Steps: step}, nil - } - - // 3. Replaying an observed Action costs no inference - result, err := client.Act(ctx, stagehand.ObservedAction(observed.Data[0]), nil) - if err != nil { - return goalOutcome{}, err - } - if !result.Data.Success { - return goalOutcome{Message: result.Data.Message, Steps: step}, nil - } - } - - return goalOutcome{Message: fmt.Sprintf("Gave up after %d steps", maxSteps), Steps: maxSteps}, nil -} + +```python +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] ``` -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) shows the same idea applied to model escalation. +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. + +To let a framework own the loop instead, the [LangChain](/v4/integrations/langchain/introduction) and [CrewAI](/v4/integrations/crew-ai/introduction) integrations expose the Stagehand primitives as agent tools, and the [MCP server](/v4/integrations/mcp/introduction) exposes them to any MCP client. + + ### Feature mapping | v3 `agent()` feature | v4 | | --- | --- | -| `agent.execute(instruction)` | The loop above | -| `maxSteps` | Your loop bound | -| Stopping when the task is done | An `extract()` goal check each pass | -| Step-by-step action selection | `observe()`, one action per pass | +| `agent.execute(instruction)` | A generated script, or a tool-calling loop you own | +| `maxSteps` | Your loop bound, or the length of the script | +| Stopping when the task is done | The script ends, or your loop's own stop condition | +| Step-by-step action selection | The full tool surface above | | `systemPrompt` | `systemPrompt` on `Stagehand.create()` | | Variables | `variables` on `act()` and `observe()`, see [prompting](/v4/best-practices/prompting-best-practices) | | Structured output | `extract()` with a schema | | MCP integrations | [MCP server](/v4/integrations/mcp/introduction) | -| Custom tools | Page-declared tools via [WebMCP](/v4/basics/webmcp), or wire the primitives into a framework agent | +| Custom tools | Page-declared tools via [WebMCP](/v4/basics/webmcp), or your own tool definitions | | Computer Use mode, `highlightCursor` | No equivalent | -| Streaming, callbacks, abort signal, message continuation | No equivalent. Your loop already sits between steps, so log, cancel, or persist there | +| Streaming, callbacks, abort signal, message continuation | No equivalent. Your loop sits between steps, so log, cancel, or persist there | + +## Let a coding assistant do the rest + +The rest of this guide is mechanical, so hand it to the same assistant that writes your v4 code. Set up [AI rules](/v4/first-steps/ai-rules) first, then point it at a file: + +```text +Migrate this file from Stagehand v3 to v4. Apply these changes: + - new Stagehand(...) + init() -> browserbase.launch() or localBrowser.launch(), + then Stagehand.create({ browser }) + - stagehand.page / stagehand.context -> await browser.context.activePage() / + browser.context + - page.act/extract/observe -> stagehand.act/extract/observe + - act/extract/observe now return { data, metadata }; read .data + - extract({ instruction, schema }) -> extract(instruction, schema) + - page.deepLocator(sel) -> page.locator(sel) + - modelName + modelClientOptions -> model: { modelName, apiKey } + - enableCaching -> cache + - verbose + logger -> logging: { level, format, onLog } + - await stagehand.metrics -> await stagehand.metrics() +Leave agent() calls alone and list them for me instead. +``` - -If you want a framework to own the loop instead of writing it yourself, the [LangChain](/v4/integrations/langchain/introduction) and [CrewAI](/v4/integrations/crew-ai/introduction) integrations expose the primitives as agent tools. - +Work through the sections below for anything it flags or misses. ## 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 the loop above. +4. Replace `agent()` calls, using either approach above. 5. Turn on server-side caching once the flow is stable. ## Breaking changes @@ -266,7 +303,7 @@ The constructor is private and `init()` is gone. Get a browser from a factory, t + 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 is already running. Stagehand closes only the browsers it launched, so call `browser.close()` yourself. See [browser configuration](/v4/configuration/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 @@ -281,6 +318,16 @@ The context moved off the Stagehand instance and onto the browser handle, and pa 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 +- 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. @@ -376,6 +423,17 @@ Metrics became a method: [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 +- 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 | @@ -384,7 +442,8 @@ Metrics became a method: | `stagehand.page` | `await browser.context.activePage()` | | `stagehand.context` | `browser.context` | | `stagehand.context.pages()` | `await browser.context.pages()` | -| `stagehand.agent()` | Your own loop | +| `page.act(...)` | `stagehand.act(...)`, with `{ page }` to target a tab | +| `stagehand.agent()` | A generated script, or your own tool-calling loop | | `await stagehand.observe(...)` returns an array | `.data` holds the array | | `extract({ instruction, schema })` | `extract(instruction, schema)` | | `modelName`, `modelClientOptions` | `model: { modelName, apiKey }` | @@ -392,6 +451,7 @@ Metrics became a method: | `verbose`, `logger` | `logging: { level, format, onLog }` | | `await stagehand.metrics` | `await stagehand.metrics()` | | `stagehand.browserbaseSessionID` | Hold the ID from `sessions.create()` | +| `page.deepLocator()` | `page.locator()`, same selector syntax | ## Troubleshooting @@ -399,24 +459,28 @@ Metrics became a method: **`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 loop never stops.** The goal check is too permissive. Ask for a strict enum, as the example does, and keep the step cap. +**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 are retrying `act()`. Retry `observe()` instead and pass the resulting action to `act()` once. +**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 - - Sequence multi-step work you own - Cut inference out of a stable flow From ae3ea44a9ff96a18fb148d42c85cbfd04c829e9f Mon Sep 17 00:00:00 2001 From: Alyssa Maruyama Date: Fri, 7 Aug 2026 01:15:35 -0500 Subject: [PATCH 4/9] fix sidebar not plural --- packages/docs/docs.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/docs.json b/packages/docs/docs.json index c366bb65d..ad7244907 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -64,7 +64,7 @@ ] }, { - "group": "Migration guides", + "group": "Migration guide", "pages": ["v4/migrations/v3"] }, { From d751f6ea2d837e77bcf6133bea55bdbb21634b59 Mon Sep 17 00:00:00 2001 From: Alyssa Maruyama Date: Fri, 7 Aug 2026 09:07:48 -0500 Subject: [PATCH 5/9] move table, clean up intro --- packages/docs/v4/migrations/v3.mdx | 39 +++++++++++------------------- 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/packages/docs/v4/migrations/v3.mdx b/packages/docs/v4/migrations/v3.mdx index a6cc10476..4e154b5c9 100644 --- a/packages/docs/v4/migrations/v3.mdx +++ b/packages/docs/v4/migrations/v3.mdx @@ -6,10 +6,10 @@ icon: 'arrow-up-right-dots' Two changes account for most of this migration: -1. **`agent()` is gone.** Nothing in v4 replaces it one-for-one. Read this part first. -2. **The SDK surface moved.** Construction, page access, and result shapes changed. Those are mechanical renames. +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. Their role changed. +`act()`, `extract()`, and `observe()` still exist and still take natural-language instructions, but their place has changed. ## Why agent() is gone @@ -239,26 +239,6 @@ TOOLS = [goto, snapshot, click, fill, read_text, act, extract] 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. - -To let a framework own the loop instead, the [LangChain](/v4/integrations/langchain/introduction) and [CrewAI](/v4/integrations/crew-ai/introduction) integrations expose the Stagehand primitives as agent tools, and the [MCP server](/v4/integrations/mcp/introduction) exposes them to any MCP client. - - -### Feature mapping - -| v3 `agent()` feature | v4 | -| --- | --- | -| `agent.execute(instruction)` | A generated script, or a tool-calling loop you own | -| `maxSteps` | Your loop bound, or the length of the script | -| Stopping when the task is done | The script ends, or your loop's own stop condition | -| Step-by-step action selection | The full tool surface above | -| `systemPrompt` | `systemPrompt` on `Stagehand.create()` | -| Variables | `variables` on `act()` and `observe()`, see [prompting](/v4/best-practices/prompting-best-practices) | -| Structured output | `extract()` with a schema | -| MCP integrations | [MCP server](/v4/integrations/mcp/introduction) | -| Custom tools | Page-declared tools via [WebMCP](/v4/basics/webmcp), or your own tool definitions | -| Computer Use mode, `highlightCursor` | No equivalent | -| Streaming, callbacks, abort signal, message continuation | No equivalent. Your loop sits between steps, so log, cancel, or persist there | - ## Let a coding assistant do the rest The rest of this guide is mechanical, so hand it to the same assistant that writes your v4 code. Set up [AI rules](/v4/first-steps/ai-rules) first, then point it at a file: @@ -443,15 +423,24 @@ Metrics became a method: | `stagehand.context` | `browser.context` | | `stagehand.context.pages()` | `await browser.context.pages()` | | `page.act(...)` | `stagehand.act(...)`, with `{ page }` to target a tab | -| `stagehand.agent()` | A generated script, or your own tool-calling loop | | `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()` | A generated script, or your own tool-calling loop | +| `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-with-the-full-api) | +| 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 }` | | `enableCaching` | `cache` | | `verbose`, `logger` | `logging: { level, format, onLog }` | | `await stagehand.metrics` | `await stagehand.metrics()` | | `stagehand.browserbaseSessionID` | Hold the ID from `sessions.create()` | -| `page.deepLocator()` | `page.locator()`, same selector syntax | ## Troubleshooting From b5192b217b26f766dfa1a408cb581f6a95a52086 Mon Sep 17 00:00:00 2001 From: Alyssa Maruyama Date: Fri, 7 Aug 2026 09:13:29 -0500 Subject: [PATCH 6/9] add todo for integrations --- packages/docs/v4/migrations/v3.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/docs/v4/migrations/v3.mdx b/packages/docs/v4/migrations/v3.mdx index 4e154b5c9..0522d39de 100644 --- a/packages/docs/v4/migrations/v3.mdx +++ b/packages/docs/v4/migrations/v3.mdx @@ -239,6 +239,8 @@ TOOLS = [goto, snapshot, click, fill, read_text, act, extract] 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. +{/* TODO: Link to agent integrations overview page */} + ## Let a coding assistant do the rest The rest of this guide is mechanical, so hand it to the same assistant that writes your v4 code. Set up [AI rules](/v4/first-steps/ai-rules) first, then point it at a file: From 3db26a8724c1ddaa30f00d6c9a16a799ce354431 Mon Sep 17 00:00:00 2001 From: Alyssa Maruyama Date: Fri, 7 Aug 2026 10:16:17 -0500 Subject: [PATCH 7/9] integrate cubic comments --- packages/docs/v4/migrations/v3.mdx | 50 ++++++++++++++++++------------ 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/packages/docs/v4/migrations/v3.mdx b/packages/docs/v4/migrations/v3.mdx index 0522d39de..f363b2934 100644 --- a/packages/docs/v4/migrations/v3.mdx +++ b/packages/docs/v4/migrations/v3.mdx @@ -15,7 +15,7 @@ Two changes account for most of this migration: `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, models are strong enough to plan against a real API. 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. +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. v4 has two approaches that replace `agent()`. @@ -78,6 +78,7 @@ try { ```python +import asyncio import os from pydantic import BaseModel @@ -93,27 +94,31 @@ class Comments(BaseModel): comments: list[Comment] -browser = await browserbase.launch(api_key=os.environ["BROWSERBASE_API_KEY"]) -stagehand = await Stagehand.create(browser=browser) +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") + 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") + # 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") + # 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() + 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()) ``` @@ -121,7 +126,7 @@ Generated code should use `page.locator()` and `page.goto()` wherever a selector ## Tool calling with the full API -To keep a model in the loop at runtime, give it the whole Stagehand surface. Models handle toolsets this size, and precise tools are easier to plan against than three broad ones. +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: @@ -395,7 +400,12 @@ Metrics became a method: - const stagehand = new Stagehand({ env: "BROWSERBASE" }); - await stagehand.init(); - console.log(stagehand.browserbaseSessionID); -+ const session = await bb.sessions.create({ projectId: process.env.BROWSERBASE_PROJECT_ID }); ++ 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, From b0cd2b3802af448fc04ad4dfc9e1cca4a4000575 Mon Sep 17 00:00:00 2001 From: Alyssa Maruyama Date: Sat, 8 Aug 2026 20:46:50 -0500 Subject: [PATCH 8/9] add python and go support, make the 2 migration approaches clear --- packages/docs/v4/migrations/v3.mdx | 186 +++++++++++++++++++++++++---- 1 file changed, 164 insertions(+), 22 deletions(-) diff --git a/packages/docs/v4/migrations/v3.mdx b/packages/docs/v4/migrations/v3.mdx index f363b2934..842a698f5 100644 --- a/packages/docs/v4/migrations/v3.mdx +++ b/packages/docs/v4/migrations/v3.mdx @@ -17,11 +17,14 @@ Two changes account for most of this migration: 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. -v4 has two approaches that replace `agent()`. +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 -Browserbase recommends this path. 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. +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. @@ -122,9 +125,73 @@ asyncio.run(main()) ``` + +```go +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 with the full API +## 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. @@ -240,6 +307,47 @@ TOOLS = [goto, snapshot, click, fill, read_text, act, extract] ``` + +```go +// 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. @@ -248,37 +356,71 @@ Escalate on `observe()`, never on `act()`. A failed `act()` may already have cli ## Let a coding assistant do the rest -The rest of this guide is mechanical, so hand it to the same assistant that writes your v4 code. Set up [AI rules](/v4/first-steps/ai-rules) first, then point it at a file: +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 -Migrate this file from Stagehand v3 to v4. Apply these changes: - - new Stagehand(...) + init() -> browserbase.launch() or localBrowser.launch(), - then Stagehand.create({ browser }) - - stagehand.page / stagehand.context -> await browser.context.activePage() / - browser.context - - page.act/extract/observe -> stagehand.act/extract/observe - - act/extract/observe now return { data, metadata }; read .data - - extract({ instruction, schema }) -> extract(instruction, schema) - - page.deepLocator(sel) -> page.locator(sel) - - modelName + modelClientOptions -> model: { modelName, apiKey } - - enableCaching -> cache - - verbose + logger -> logging: { level, format, onLog } - - await stagehand.metrics -> await stagehand.metrics() -Leave agent() calls alone and list them for me instead. +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. ``` -Work through the sections below for anything it flags or misses. +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, using either approach above. +4. Replace `agent()` calls with [code mode](#code-mode) or [tool calling](#tool-calling). 5. Turn on server-side caching once the flow is stable. +## 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 +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 +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: "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()`: @@ -438,13 +580,13 @@ Metrics became a method: | `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()` | A generated script, or your own tool-calling loop | +| `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-with-the-full-api) | +| 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 | From 698c8be38f9c5a5890535a33b0cb9dbf6b641144 Mon Sep 17 00:00:00 2001 From: Alyssa Keimach <7604716+akeimach@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:03:39 -0700 Subject: [PATCH 9/9] Update packages/docs/v4/migrations/v3.mdx Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- packages/docs/v4/migrations/v3.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/v4/migrations/v3.mdx b/packages/docs/v4/migrations/v3.mdx index 842a698f5..62e55493c 100644 --- a/packages/docs/v4/migrations/v3.mdx +++ b/packages/docs/v4/migrations/v3.mdx @@ -399,7 +399,7 @@ 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: "gpt-5-nano"}) +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",