Skip to content

Commit c9fefcc

Browse files
authored
Made frameId optional (#266)
* Made frameId optional * Added link to local_example in readme. * Minimal working byob example * Working bring-your-own-browser-driver example interleaving playwright with stagehand.
1 parent 8c3a33b commit c9fefcc

File tree

7 files changed

+114
-17
lines changed

7 files changed

+114
-17
lines changed

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ export MODEL_API_KEY="sk-proj-your-llm-api-key"
3232
uv run python examples/full_example.py
3333
```
3434

35+
## Local mode example
36+
37+
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"`:
38+
39+
```bash
40+
pip install stagehand-alpha
41+
uv run python examples/local_example.py
42+
```
43+
3544
<details>
3645
<summary><strong>Local development</strong></summary>
3746

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

examples/act_example.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ async def main() -> None:
3838
# Navigate to example.com
3939
await session.navigate(
4040
url="https://www.example.com",
41-
frame_id="", # Empty string for main frame
4241
)
4342
print("Navigated to example.com")
4443

examples/byob_example.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
"""
4+
Example showing how to bring your own browser driver while still using Stagehand.
5+
6+
This script runs Playwright locally to drive the browser and uses Stagehand to
7+
plan the interactions (observe → extract) without having Stagehand own the page.
8+
9+
Required environment variables:
10+
- BROWSERBASE_API_KEY
11+
- BROWSERBASE_PROJECT_ID
12+
- MODEL_API_KEY
13+
14+
Usage:
15+
16+
```
17+
pip install playwright stagehand-alpha
18+
# (if Playwright is new) playwright install chromium
19+
uv run python examples/byob_example.py
20+
```
21+
"""
22+
23+
import os
24+
import asyncio
25+
26+
from playwright.async_api import async_playwright
27+
28+
from stagehand import AsyncStagehand
29+
30+
31+
async def main() -> None:
32+
async with AsyncStagehand(
33+
browserbase_api_key=os.environ.get("BROWSERBASE_API_KEY"),
34+
browserbase_project_id=os.environ.get("BROWSERBASE_PROJECT_ID"),
35+
model_api_key=os.environ.get("MODEL_API_KEY"),
36+
) as client, async_playwright() as playwright:
37+
browser = await playwright.chromium.launch(headless=True)
38+
page = await browser.new_page()
39+
session = await client.sessions.create(model_name="openai/gpt-5-nano")
40+
41+
try:
42+
target_url = "https://news.ycombinator.com"
43+
await session.navigate(url=target_url)
44+
await page.goto(target_url, wait_until="networkidle")
45+
46+
print("🎯 Stagehand already navigated to Hacker News; Playwright now drives that page.")
47+
48+
# Click the first story's comments link with Playwright.
49+
comments_selector = "tr.athing:first-of-type + tr .subline > a[href^='item?id=']:nth-last-of-type(1)"
50+
await page.click(comments_selector, timeout=15_000)
51+
await page.wait_for_load_state("networkidle")
52+
53+
print("✅ Playwright clicked the first story link.")
54+
55+
print("🔄 Syncing Stagehand to Playwright's current URL:", page.url)
56+
await session.navigate(url=page.url)
57+
58+
extract_response = await session.extract(
59+
instruction="extract the text of the top comment on this page",
60+
schema={
61+
"type": "object",
62+
"properties": {"comment": {"type": "string"}},
63+
"required": ["comment"],
64+
},
65+
)
66+
67+
print("🧮 Stagehand extraction result:", extract_response.data.result)
68+
finally:
69+
await session.end()
70+
await browser.close()
71+
72+
73+
if __name__ == "__main__":
74+
asyncio.run(main())

examples/full_example.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ async def main() -> None:
4040
# Navigate to Hacker News
4141
await session.navigate(
4242
url="https://news.ycombinator.com",
43-
frame_id="", # Empty string for main frame
4443
)
4544
print("Navigated to Hacker News")
4645

examples/local_example.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ def main() -> None:
5252
client.sessions.navigate(
5353
id=session_id,
5454
url="https://www.example.com",
55-
frame_id="",
5655
)
5756
print("✅ Navigation complete")
5857

src/stagehand/session.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from typing import TYPE_CHECKING, Union
5+
from typing import TYPE_CHECKING, Any, Union, Mapping, TypeVar, cast
66
from datetime import datetime
77
from typing_extensions import Unpack, Literal
88

@@ -23,6 +23,15 @@
2323
from .types.session_observe_response import SessionObserveResponse
2424
from .types.session_navigate_response import SessionNavigateResponse
2525

26+
TSessionParams = TypeVar("TSessionParams", bound=Mapping[str, Any])
27+
28+
29+
def _with_default_frame_id(params: TSessionParams) -> TSessionParams:
30+
prepared = dict(params)
31+
if "frame_id" not in prepared:
32+
prepared["frame_id"] = ""
33+
return cast(TSessionParams, prepared)
34+
2635
if TYPE_CHECKING:
2736
from ._client import Stagehand, AsyncStagehand
2837

@@ -49,7 +58,7 @@ def navigate(
4958
extra_query=extra_query,
5059
extra_body=extra_body,
5160
timeout=timeout,
52-
**params,
61+
**_with_default_frame_id(params),
5362
)
5463

5564
def act(
@@ -67,7 +76,7 @@ def act(
6776
extra_query=extra_query,
6877
extra_body=extra_body,
6978
timeout=timeout,
70-
**params,
79+
**_with_default_frame_id(params),
7180
)
7281

7382
def observe(
@@ -85,7 +94,7 @@ def observe(
8594
extra_query=extra_query,
8695
extra_body=extra_body,
8796
timeout=timeout,
88-
**params,
97+
**_with_default_frame_id(params),
8998
)
9099

91100
def extract(
@@ -103,7 +112,7 @@ def extract(
103112
extra_query=extra_query,
104113
extra_body=extra_body,
105114
timeout=timeout,
106-
**params,
115+
**_with_default_frame_id(params),
107116
)
108117

109118
def execute(
@@ -121,7 +130,7 @@ def execute(
121130
extra_query=extra_query,
122131
extra_body=extra_body,
123132
timeout=timeout,
124-
**params,
133+
**_with_default_frame_id(params),
125134
)
126135

127136
def end(
@@ -171,7 +180,7 @@ async def navigate(
171180
extra_query=extra_query,
172181
extra_body=extra_body,
173182
timeout=timeout,
174-
**params,
183+
**_with_default_frame_id(params),
175184
)
176185

177186
async def act(
@@ -189,7 +198,7 @@ async def act(
189198
extra_query=extra_query,
190199
extra_body=extra_body,
191200
timeout=timeout,
192-
**params,
201+
**_with_default_frame_id(params),
193202
)
194203

195204
async def observe(
@@ -207,7 +216,7 @@ async def observe(
207216
extra_query=extra_query,
208217
extra_body=extra_body,
209218
timeout=timeout,
210-
**params,
219+
**_with_default_frame_id(params),
211220
)
212221

213222
async def extract(
@@ -225,7 +234,7 @@ async def extract(
225234
extra_query=extra_query,
226235
extra_body=extra_body,
227236
timeout=timeout,
228-
**params,
237+
**_with_default_frame_id(params),
229238
)
230239

231240
async def execute(
@@ -243,7 +252,7 @@ async def execute(
243252
extra_query=extra_query,
244253
extra_body=extra_body,
245254
timeout=timeout,
246-
**params,
255+
**_with_default_frame_id(params),
247256
)
248257

249258
async def end(

tests/test_sessions_create_helper.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
from __future__ import annotations
44

55
import os
6+
import json
7+
from typing import cast
68

79
import httpx
810
import pytest
911
from respx import MockRouter
12+
from respx.models import Call
1013

1114
from stagehand import Stagehand, AsyncStagehand
1215

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

40-
session.navigate(url="https://example.com", frame_id="")
43+
session.navigate(url="https://example.com")
4144
assert navigate_route.called is True
45+
first_call = cast(Call, navigate_route.calls[0])
46+
request_body = json.loads(first_call.request.content)
47+
assert request_body["frameId"] == ""
4248

4349

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

70-
await session.navigate(url="https://example.com", frame_id="")
76+
await session.navigate(url="https://example.com")
7177
assert navigate_route.called is True
78+
first_call = cast(Call, navigate_route.calls[0])
79+
request_body = json.loads(first_call.request.content)
80+
assert request_body["frameId"] == ""

0 commit comments

Comments
 (0)