Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ export MODEL_API_KEY="sk-proj-your-llm-api-key"
uv run python examples/full_example.py
```

## Local mode example

If you want to run Stagehand locally, use the local example (`examples/local_example.py`). It shows how to configure the client for `server="local"`:

```bash
pip install stagehand-alpha
uv run python examples/local_example.py
```

<details>
<summary><strong>Local development</strong></summary>

Expand Down Expand Up @@ -73,7 +82,6 @@ async def main() -> None:
# Navigate to a webpage
await session.navigate(
url="https://news.ycombinator.com",
frame_id="", # empty string for the main frame
)
print("Navigated to Hacker News")

Expand Down
1 change: 0 additions & 1 deletion examples/act_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ async def main() -> None:
# Navigate to example.com
await session.navigate(
url="https://www.example.com",
frame_id="", # Empty string for main frame
)
print("Navigated to example.com")

Expand Down
74 changes: 74 additions & 0 deletions examples/byob_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from __future__ import annotations

"""
Example showing how to bring your own browser driver while still using Stagehand.

This script runs Playwright locally to drive the browser and uses Stagehand to
plan the interactions (observe → extract) without having Stagehand own the page.

Required environment variables:
- BROWSERBASE_API_KEY
- BROWSERBASE_PROJECT_ID
- MODEL_API_KEY

Usage:

```
pip install playwright stagehand-alpha
# (if Playwright is new) playwright install chromium
uv run python examples/byob_example.py
```
"""

import os
import asyncio

from playwright.async_api import async_playwright

from stagehand import AsyncStagehand


async def main() -> None:
async with AsyncStagehand(
browserbase_api_key=os.environ.get("BROWSERBASE_API_KEY"),
browserbase_project_id=os.environ.get("BROWSERBASE_PROJECT_ID"),
model_api_key=os.environ.get("MODEL_API_KEY"),
) as client, async_playwright() as playwright:
browser = await playwright.chromium.launch(headless=True)
Copy link

@cubic-dev-ai cubic-dev-ai bot Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This example doesn't achieve the stated BYOB (Bring Your Own Browser) goal. The AsyncStagehand client uses the default server="remote" mode, creating a browser session on Browserbase that is completely independent from the local Playwright browser. These two browsers never share the same page - they're just navigating to the same URLs independently.

To actually demonstrate BYOB, consider using server="local" mode, or clarify the example's purpose to show how to coordinate between two separate browser sessions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At examples/byob_example.py, line 37:

<comment>This example doesn't achieve the stated BYOB (Bring Your Own Browser) goal. The `AsyncStagehand` client uses the default `server="remote"` mode, creating a browser session on Browserbase that is completely independent from the local Playwright browser. These two browsers never share the same page - they're just navigating to the same URLs independently.

To actually demonstrate BYOB, consider using `server="local"` mode, or clarify the example's purpose to show how to coordinate between two separate browser sessions.</comment>

<file context>
@@ -0,0 +1,74 @@
+        browserbase_project_id=os.environ.get("BROWSERBASE_PROJECT_ID"),
+        model_api_key=os.environ.get("MODEL_API_KEY"),
+    ) as client, async_playwright() as playwright:
+        browser = await playwright.chromium.launch(headless=True)
+        page = await browser.new_page()
+        session = await client.sessions.create(model_name="openai/gpt-5-nano")
</file context>
Fix with Cubic

page = await browser.new_page()
session = await client.sessions.create(model_name="openai/gpt-5-nano")

try:
target_url = "https://news.ycombinator.com"
await session.navigate(url=target_url)
await page.goto(target_url, wait_until="networkidle")

print("🎯 Stagehand already navigated to Hacker News; Playwright now drives that page.")

# Click the first story's comments link with Playwright.
comments_selector = "tr.athing:first-of-type + tr .subline > a[href^='item?id=']:nth-last-of-type(1)"
await page.click(comments_selector, timeout=15_000)
await page.wait_for_load_state("networkidle")

print("✅ Playwright clicked the first story link.")

print("🔄 Syncing Stagehand to Playwright's current URL:", page.url)
await session.navigate(url=page.url)

extract_response = await session.extract(
instruction="extract the text of the top comment on this page",
schema={
"type": "object",
"properties": {"comment": {"type": "string"}},
"required": ["comment"],
},
)

print("🧮 Stagehand extraction result:", extract_response.data.result)
finally:
await session.end()
await browser.close()


if __name__ == "__main__":
asyncio.run(main())
1 change: 0 additions & 1 deletion examples/full_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ async def main() -> None:
# Navigate to Hacker News
await session.navigate(
url="https://news.ycombinator.com",
frame_id="", # Empty string for main frame
)
print("Navigated to Hacker News")

Expand Down
1 change: 0 additions & 1 deletion examples/local_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ def main() -> None:
client.sessions.navigate(
id=session_id,
url="https://www.example.com",
frame_id="",
)
print("✅ Navigation complete")

Expand Down
31 changes: 20 additions & 11 deletions src/stagehand/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from typing import TYPE_CHECKING, Union
from typing import TYPE_CHECKING, Any, Union, Mapping, TypeVar, cast
from datetime import datetime
from typing_extensions import Unpack, Literal

Expand All @@ -23,6 +23,15 @@
from .types.session_observe_response import SessionObserveResponse
from .types.session_navigate_response import SessionNavigateResponse

TSessionParams = TypeVar("TSessionParams", bound=Mapping[str, Any])


def _with_default_frame_id(params: TSessionParams) -> TSessionParams:
prepared = dict(params)
if "frame_id" not in prepared:
prepared["frame_id"] = ""
return cast(TSessionParams, prepared)

if TYPE_CHECKING:
from ._client import Stagehand, AsyncStagehand

Expand All @@ -49,7 +58,7 @@ def navigate(
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
**params,
**_with_default_frame_id(params),
)

def act(
Expand All @@ -67,7 +76,7 @@ def act(
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
**params,
**_with_default_frame_id(params),
)

def observe(
Expand All @@ -85,7 +94,7 @@ def observe(
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
**params,
**_with_default_frame_id(params),
)

def extract(
Expand All @@ -103,7 +112,7 @@ def extract(
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
**params,
**_with_default_frame_id(params),
)

def execute(
Expand All @@ -121,7 +130,7 @@ def execute(
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
**params,
**_with_default_frame_id(params),
)

def end(
Expand Down Expand Up @@ -171,7 +180,7 @@ async def navigate(
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
**params,
**_with_default_frame_id(params),
)

async def act(
Expand All @@ -189,7 +198,7 @@ async def act(
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
**params,
**_with_default_frame_id(params),
)

async def observe(
Expand All @@ -207,7 +216,7 @@ async def observe(
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
**params,
**_with_default_frame_id(params),
)

async def extract(
Expand All @@ -225,7 +234,7 @@ async def extract(
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
**params,
**_with_default_frame_id(params),
)

async def execute(
Expand All @@ -243,7 +252,7 @@ async def execute(
extra_query=extra_query,
extra_body=extra_body,
timeout=timeout,
**params,
**_with_default_frame_id(params),
)

async def end(
Expand Down
13 changes: 11 additions & 2 deletions tests/test_sessions_create_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
from __future__ import annotations

import os
import json
from typing import cast

import httpx
import pytest
from respx import MockRouter
from respx.models import Call

from stagehand import Stagehand, AsyncStagehand

Expand Down Expand Up @@ -37,8 +40,11 @@ def test_sessions_create_returns_bound_session(respx_mock: MockRouter, client: S
session = client.sessions.create(model_name="openai/gpt-5-nano")
assert session.id == session_id

session.navigate(url="https://example.com", frame_id="")
session.navigate(url="https://example.com")
assert navigate_route.called is True
first_call = cast(Call, navigate_route.calls[0])
request_body = json.loads(first_call.request.content)
assert request_body["frameId"] == ""


@pytest.mark.respx(base_url=base_url)
Expand Down Expand Up @@ -67,5 +73,8 @@ async def test_async_sessions_create_returns_bound_session(
session = await async_client.sessions.create(model_name="openai/gpt-5-nano")
assert session.id == session_id

await session.navigate(url="https://example.com", frame_id="")
await session.navigate(url="https://example.com")
assert navigate_route.called is True
first_call = cast(Call, navigate_route.calls[0])
request_body = json.loads(first_call.request.content)
assert request_body["frameId"] == ""
Loading