Stagehand: Building Reliable, AI-Assisted Browser Automation

A browser UI is a moving target. A button label changes, a modal appears, or a CSS selector is refactored, and a sensible automation stops working. Stagehand addresses this awkward middle ground: it lets you use AI where a page is ambiguous, while retaining ordinary, deterministic browser code where you already know what must happen. In practice, that makes it a useful SDK for teams that need browser automation to be adaptable without turning every workflow into an opaque autonomous agent.

This Python-focused article targets the Stagehand Python SDK v3. It explains the asynchronous session API: session.navigate(), session.act(), session.observe(), session.extract(), and session.execute(). It deliberately focuses on SDK and implementation patterns rather than repeating the broader agent-loop and governance discussion in the companion Browser Use guide.

1. What Stagehand Is, and Where It Fits

Stagehand is an open-source browser-automation framework from Browserbase. It supports TypeScript, Python, and Go. In Python, its central design idea is simple: an LLM should help only where browser automation needs interpretation, while regular code should continue to handle the portions that are already deterministic.

Traditional browser frameworks such as Playwright are excellent when you know the page structure. A selector such as button[data-testid="checkout"] is fast, inspectable, and does not require an LLM. Its weakness is brittleness when the target application changes that structure. At the other extreme, a free-running browser agent can reason about a changing page, but its decisions can be expensive and hard to constrain.

Stagehand is designed to let you choose the point between those extremes on every step:

NeedPreferWhy
A known URLawait session.navigate(url="...")Direct navigation without an inference request
A control whose location or wording may driftawait session.act(input="...")A natural-language action can re-locate the intended control
A page must be understood before any actionawait session.observe(instruction="...")It returns candidate actions for application code to inspect
Page data must enter another systemawait session.extract(instruction="...", schema=...)It returns a result that can be validated against a caller-provided schema
A bounded, multi-step browser task needs planningawait session.execute(execute_options=..., agent_config=...)Stagehand can run an agent workflow; application code still owns the business policy

The final row matters. Stagehand can be part of an agent harness, but it does not remove the need for workflow state, authorization, human approval, or retry policy. Treat its agent capability as a browser component, rather than as a replacement for those system boundaries.

This boundary is also useful when deciding when not to use an AI agent: if a stable selector and deterministic code solve the task, adding model inference creates cost and another failure mode without a compensating benefit.

2. The Four Layers of Control

Stagehand v3 provides synchronous Stagehand and asynchronous AsyncStagehand clients. This article uses AsyncStagehand: create one client, start a browser session with await client.sessions.start(...), and use the bound session object for browser operations. End the session with await session.end(); the async client context manager closes the HTTP client and, in local mode, shuts down the embedded server.

2.1 Deterministic Navigation

Use session.navigate() when the destination URL is already known. This keeps navigation deterministic and outside the model loop:

Python
await session.navigate(url="https://example.com/settings")

This is the baseline. It is easy to test, explain in a code review, and retry under a clearly defined policy. For a known DOM-level interaction, Stagehand v3 can target a connected Playwright page by passing page=page to a session operation; use Playwright itself for deterministic locator work.

2.2 observe(): Discover Before Committing

session.observe() asks Stagehand to find candidate, actionable page elements. It returns structured action data in observed.data.result. Your application can filter those candidates against its own rules before accepting one.

Python
observed = await session.observe(
    instruction="find the primary button that continues to the account-review step",
)
candidates = observed.data.result

An observed action is useful evidence, not permission. For a low-risk button, code might require action.method == "click" and an expected description. For a high-impact operation, it should also require a server-side approval or an explicit policy decision.

2.3 act(): Perform an Intentional Instruction

session.act() accepts a natural-language instruction or an action dictionary created from session.observe(), then returns information about the action or actions performed:

Python
# Inference finds a suitable target on the current page.
await session.act(input="open the filters panel")

# This sends the observed action payload back to Stagehand.
await session.act(input=candidates[0].to_dict(exclude_none=True))

The first form is adaptive. The second form sends the application-selected observed action rather than asking Stagehand to find a fresh target. Neither form proves that the target is semantically safe. Validate the action before sending it and verify consequential results afterward. Set self_heal=True when starting the session only after testing its behavior for the workflow.

2.4 extract(): Turn a Page into Typed Data

session.extract() accepts a JSON Schema and can also accept a Pydantic model class. The response value is held in response.data.result. When given a Pydantic model, the SDK attempts to validate the result and returns the model instance on success; explicitly validate the value before relying on it.

Python
from pydantic import BaseModel, Field


class Price(BaseModel):
    annual_usd: float = Field(ge=0)


response = await session.extract(
    instruction="extract the displayed annual price in US dollars",
    schema=Price,
)
raw_price = response.data.result
price = raw_price if isinstance(raw_price, Price) else Price.model_validate(raw_price)

Schema validation means the answer has the required shape. It does not establish that the page was trustworthy, that the extracted value was current, or that the model interpreted the correct offer. Treat a schema as a parsing contract, then add business checks for truth and relevance.

stagehand-control-spectrum

3. How the AI Primitives Work

A natural-language browser instruction requires Stagehand to interpret the relevant browser context before it can find an element or return structured data. Conceptually, an AI-assisted call has four stages:

  1. Scope the page context. Use options={"selector": "..."} with observe() or extract() to narrow the target region when appropriate. A session operation can also target a supplied connected Playwright page or a frame_id.
  2. Interpret the instruction. A model relates the instruction, such as “find the checkout button,” to the scoped page information.
  3. Produce a structured result. session.observe() produces candidate actions, session.act() performs a bounded action, and session.extract() returns data under response.data.result for the application to validate against its schema.
  4. Record application evidence. Store the browser or external session identifier where available, source URL, request outcome, and elapsed time with each consequential operation.

The value of scoping is practical. More irrelevant page content can increase token cost and give the model more plausible but wrong targets. Each AI call has a cost, so workflows become more expensive when they make more calls or give the model more page content to read.

Reducing the number of calls and keeping requests focused usually makes the workflow faster and cheaper. More importantly, it reduces the chances that the model will make an ambiguous interpretation.

3.1 Scope the Request Before You Ask

Navigate to the relevant page and state the intended region precisely. Extraction accepts a CSS selector through options. For example:

Python
from pydantic import BaseModel, Field


class Plan(BaseModel):
    name: str = Field(min_length=1)
    monthly_price: str = Field(min_length=1)


class Pricing(BaseModel):
    plans: list[Plan]


response = await session.extract(
    instruction="From the pricing table, extract each plan name and its monthly price.",
    schema=Pricing,
    options={"selector": "table.pricing"},
)
raw_pricing = response.data.result
pricing = raw_pricing if isinstance(raw_pricing, Pricing) else Pricing.model_validate(raw_pricing)

The instruction is a semantic constraint, while options={"selector": "..."} limits the extraction to a CSS-selected page region. Use a page-specific selector that identifies the intended region. If unrelated content could alter a high-value result, also validate it against an independent source or navigate to a simpler, purpose-built page.

3.2 Use Screenshots Only for Visual Facts

Use options={"screenshot": True} with session.extract() only when the needed information exists in pixels rather than page text, for example a sale badge or chart label. It includes a screenshot of the current viewport in the extraction call. Treat visual output as evidence that still needs application validation.

stagehand-scoped-extraction

4. A Runnable Python Example

Install the Stagehand Python v3 SDK, Pydantic, and python-dotenv in a Python 3.9-or-newer environment. Local runs also require Chrome or Chromium to be installed:

Bash
uv pip install "stagehand>=3.23,<4" "pydantic>=2" python-dotenv
# Or: pip install "stagehand>=3.23,<4" "pydantic>=2" python-dotenv

The example uses Stagehand v3 local mode. AsyncStagehand(server="local", ...) starts the embedded local server, and session = await client.sessions.start(...) launches the local browser. The activity remains read-only and demonstrates navigation, extraction, action discovery, application validation, and execution of an approved action. Store a Google key in GOOGLE_API_KEY; the example uses google/gemini-3.5-flash-lite, in a local .env file that is excluded from version control.

Python
import asyncio
import os

from dotenv import load_dotenv
from pydantic import BaseModel, Field, HttpUrl
from stagehand import AsyncStagehand

load_dotenv()


class Book(BaseModel):
    title: str = Field(min_length=1)
    price_gbp: str = Field(min_length=1)
    product_url: HttpUrl


class BookResults(BaseModel):
    books: list[Book] = Field(min_length=3, max_length=3)


async def main() -> None:
    google_api_key = os.environ.get("GOOGLE_API_KEY")
    if not google_api_key:
        raise RuntimeError("Set GOOGLE_API_KEY before running this local-browser example.")
    model_name = "google/gemini-3.5-flash-lite"

    async with AsyncStagehand(
        server="local",
        model_api_key=google_api_key,
        local_ready_timeout_s=30.0,
    ) as client:
        session = await client.sessions.start(
            model_name=model_name,
            browser={"type": "local", "launch_options": {"headless": True}},
        )
        try:
            await session.navigate(url="https://books.toscrape.com/")

            response = await session.extract(
                instruction=(
                    "Extract exactly the first three books. Return each title, displayed "
                    "price in GBP, and product URL."
                ),
                schema=BookResults,
            )
            raw_books = response.data.result
            books = (
                raw_books
                if isinstance(raw_books, BookResults)
                else BookResults.model_validate(raw_books)
            )
            for book in books.books:
                print(f"{book.title}: {book.price_gbp} ({book.product_url})")

            # Discover an action first. The program, not the model, decides whether
            # the candidate satisfies the low-risk policy for this read-only demo.
            observed = await session.observe(
                instruction="find the Next button in the pagination control",
            )
            next_action = next(
                (
                    action
                    for action in observed.data.result or []
                    if action.method == "click" and "next" in action.description.lower()
                ),
                None,
            )
            if next_action is None:
                raise RuntimeError("A safe pagination action was not found.")

            # Send the application-approved action object back to Stagehand.
            await session.act(input=next_action.to_dict(exclude_none=True))
            print("Advanced to the next catalogue page.")
        finally:
            await session.end()


if __name__ == "__main__":
    asyncio.run(main())

The description returned by session.observe() is not an application policy field and can vary. A real application should apply stronger validation than a substring match, for example an expected URL, page state, action method, allowed target region, and a server-side policy for anything that changes data. The code is intentionally a pattern demonstration, not an authorization system.

4.1 Local Server and Session Lifecycle

The v3 client starts the embedded server lazily when its first local request runs. Start the browser with await client.sessions.start(model_name=..., browser={"type": "local", ...}), then end it with await session.end(). Exiting the AsyncStagehand context closes its HTTP resources and shuts down the embedded local server. For a remote Browserbase session, omit server="local", configure both a Browserbase key and a model key on the client, and start the session with browser={"type": "browserbase"}.

5. The Most Useful Pattern: Observe, Validate, Then Act

The session.observe() result bridges AI flexibility and deterministic execution. It lets code inspect what the model found before an action occurs. This is particularly valuable for login, checkout, deletion, and publishing workflows.

Python
observed = await session.observe(
    instruction="find the button that submits the expense report",
)
submit = next(
    (
        action
        for action in observed.data.result or []
        if action.method == "click" and "submit" in action.description.lower()
    ),
    None,
)

if submit is None or not policy.allows(
    action=submit,
    user_id=user_id,
    report_id=report_id,
):
    raise PermissionError("Submission is not authorized.")

await session.act(input=submit.to_dict(exclude_none=True))

Passing the serialized observed action to session.act() gives Stagehand the application-approved action object instead of a new natural-language request. That is useful for benign UI drift, but for an important action, re-check the target and verify the resulting business state. If self_heal=True is enabled when the session starts, include a policy for its behavior when a target is stale.

This observe-validate-act boundary is a browser-specific form of LLM guardrails: keep authorization decisions in application code, not in a model’s interpretation of a page.

6. Make Extraction Useful to Downstream Code

Extraction becomes robust when the schema and instruction express the business contract together. Start with the smallest useful shape and describe ambiguous fields precisely.

Python
from pydantic import BaseModel, Field, HttpUrl


class Job(BaseModel):
    title: str = Field(min_length=1, description="The visible job title")
    location: str = Field(min_length=1, description="The stated work location")
    apply_url: HttpUrl = Field(description="The job's direct application URL")
    posted_date: str | None = Field(description="None when no date is shown")


response = await session.extract(
    instruction="Extract the job posting details. Do not infer a date if the page does not show one.",
    schema=Job,
)
raw_job = response.data.result
job = raw_job if isinstance(raw_job, Job) else Job.model_validate(raw_job)

Useful follow-up checks are domain-specific:

  1. Check source identity. Require the expected hostname and record the source URL with the data.
  2. Check freshness. Reject or flag content whose visible date or retrieval time is too old for the use case.
  3. Check invariants. Validate currency, units, allowed categories, numeric ranges, and duplicates.
  4. Cross-check high-value fields. Compare a critical price, status, or identifier with an independent source or a version-compatible deterministic browser check.
  5. Keep raw evidence appropriately. Store an approved page snapshot, source URL, or screenshot reference when an operator must later review a decision. Redact personal and secret data.

The right level of validation depends on the cost of an error. Use model calibration and uncertainty-aware design when a model’s confidence is part of a downstream decision, but do not mistake a confident extraction for verified truth.

7. Reliability and Cost Engineering

7.1 Break Work into Small Steps

session.act() works best for one intentional transition at a time. Prefer:

Python
await session.act(input="open the filters panel")
await session.act(input="choose four-star rating")
await session.act(input="apply the filters")

over a compound instruction that blends navigation, selection, judgement, and submission. Small steps create useful checkpoints. They make failures local, simplify retries, and allow your code to inspect the page state between actions.

7.2 Cache Repeated, Non-Sensitive Work Carefully

Do not assume that act(), observe(), or extract() exposes a cache switch or cache metadata. The multi-step session.execute() operation accepts should_cache=True to ask the service to capture a cache entry. Test caching behavior, scope, and invalidation before using it in a production workflow.

Caching is appropriate only for a repeatedly visited, stable, public workflow. It is not a default for dynamic pages, credential-bearing calls, or workflows where stale behavior could matter. When session.execute() returns a cache_entry, record its opaque cache_key with the operation evidence.

7.3 Measure the Whole Browser Operation

Measure wall-clock time around each session operation in application code. For recorded session activity, call await client.sessions.replay(id=session.id) and inspect its page actions, durations, and any available token-usage fields. Combine those signals with action failures and task correctness.

Emit the same operation identifiers and timing data into OpenTelemetry traces where possible. Correlating browser steps with the request, policy, and downstream service spans makes a slow or incorrect outcome diagnosable across the whole workflow.

A useful dashboard separates:

  • Outcome quality: percentage of outputs that pass both schema and business validation.
  • Operational reliability: completion rate, action-error rate, retry count, and median time to a verified result.
  • Model efficiency: measured inference duration, available provider usage data, cache-hit rate when supported, and cost per verified outcome.
  • Safety behavior: blocked domain changes, denied actions, approval requests, and secret-handling violations.

This framing prevents a familiar trap: optimizing token cost while quietly degrading task success. The broader agentic-system evaluation discipline applies here, even when Stagehand is only the browser layer.

stagehand-observe-act-reliability-loop

8. Testing a Stagehand Workflow

Do not evaluate a browser automation only on the page used during development. A credible test set includes pages and states that resemble production variation:

Test caseWhat to verify
Copy or layout changesAn AI-assisted action still chooses the intended semantic target, or fails clearly
Missing targetThe workflow stops with an actionable error instead of clicking a related control
Delayed contentTimeouts and retries stay bounded, then surface the failure
Stale authenticationThe workflow requests reauthentication rather than improvising credentials
Untrusted page instructionApplication policy, not page text, controls what the automation can do
Changed target after observationUsing a stored observed action does not bypass a required approval or verification step
Schema-valid but wrong dataBusiness validation catches the wrong currency, domain, range, or record

For state-changing steps, use a staging application or test account and assert the external result directly. A click call returning success proves that a browser operation completed, not that the intended business transaction is correct. Testing machine learning code provides complementary principles for separating unit, integration, and end-to-end tests.

Treat instructions found in browser content as untrusted input. A page can attempt to redirect the model’s task, so defend this browser workflow against prompt injection with scoped instructions, allowlisted actions, and approval checks that do not depend on page text.

9. Production Checklist

Before releasing a Stagehand workflow, confirm the following:

  • Every AI-assisted step has a clearly defined purpose, input scope, timeout, and failure path.
  • Known navigation uses session.navigate() rather than an unnecessary model call.
  • session.observe() results are validated in application code before sensitive or state-changing actions.
  • session.extract() uses JSON Schema or a Pydantic model, and response.data.result also passes domain-specific rules.
  • Browser identities and credentials use least privilege, with a separate test profile or account.
  • Production secrets come from a managed secret store or deployment-injected environment variable; a gitignored .env file is for local development only.
  • Sensitive values use options={"variables": ...} with session.observe() and session.act(), rather than being interpolated into an instruction.
  • Any available caching is evaluated for correctness as well as cost before production use.
  • The workflow records source URLs, relevant browser/session identifiers, operation metadata, policy decisions, and redacted evidence.
  • Tests cover UI variation, slow pages, missing elements, stale sessions, and prompt-injection attempts.
  • Every important mutation has a post-condition check and a safe retry strategy.

Summary

Stagehand is most useful when you do not have to choose between a brittle browser flow and a completely unconstrained browser agent. In the Stagehand Python v3 asynchronous client, use session.navigate() for known destinations, session.observe() to discover uncertain targets, application code to validate them, session.act() to execute a bounded step, and session.extract() with JSON Schema or a Pydantic model to retrieve structured data from response.data.result. Use session.execute() only for a bounded multi-step task, with application-defined authorization and post-condition checks around it.

Website |  + posts

Silpa brings 5 years of experience in working on diverse ML projects, specializing in designing end-to-end ML systems tailored for real-time applications. Her background in statistics (Bachelor of Technology) provides a strong foundation for her work in the field. Silpa is also the driving force behind the development of the content you find on this site.

Subscribe to our newsletter!