From b1db99f1c973b86aa095493eb43ed9b6bf415bee Mon Sep 17 00:00:00 2001 From: monadoid Date: Fri, 9 Jan 2026 17:45:04 -0700 Subject: [PATCH 1/4] Made frameId optional --- README.md | 1 - examples/act_example.py | 1 - examples/full_example.py | 1 - examples/local_example.py | 1 - src/stagehand/session.py | 31 ++++++++++++++++++---------- tests/test_sessions_create_helper.py | 13 ++++++++++-- 6 files changed, 31 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 1800f53..105f91d 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,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") diff --git a/examples/act_example.py b/examples/act_example.py index 377c1df..c472983 100644 --- a/examples/act_example.py +++ b/examples/act_example.py @@ -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") diff --git a/examples/full_example.py b/examples/full_example.py index e6b1997..dcba741 100644 --- a/examples/full_example.py +++ b/examples/full_example.py @@ -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") diff --git a/examples/local_example.py b/examples/local_example.py index d33ecee..2d0ae71 100644 --- a/examples/local_example.py +++ b/examples/local_example.py @@ -52,7 +52,6 @@ def main() -> None: client.sessions.navigate( id=session_id, url="https://www.example.com", - frame_id="", ) print("✅ Navigation complete") diff --git a/src/stagehand/session.py b/src/stagehand/session.py index eebea84..292243f 100644 --- a/src/stagehand/session.py +++ b/src/stagehand/session.py @@ -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 @@ -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 @@ -49,7 +58,7 @@ def navigate( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - **params, + **_with_default_frame_id(params), ) def act( @@ -67,7 +76,7 @@ def act( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - **params, + **_with_default_frame_id(params), ) def observe( @@ -85,7 +94,7 @@ def observe( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - **params, + **_with_default_frame_id(params), ) def extract( @@ -103,7 +112,7 @@ def extract( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - **params, + **_with_default_frame_id(params), ) def execute( @@ -121,7 +130,7 @@ def execute( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - **params, + **_with_default_frame_id(params), ) def end( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( diff --git a/tests/test_sessions_create_helper.py b/tests/test_sessions_create_helper.py index 1236119..f9c9d99 100644 --- a/tests/test_sessions_create_helper.py +++ b/tests/test_sessions_create_helper.py @@ -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 @@ -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) @@ -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"] == "" From 90a6f9d45bee014e98d1cfcfcda06c2862df6d9b Mon Sep 17 00:00:00 2001 From: monadoid Date: Fri, 9 Jan 2026 17:48:25 -0700 Subject: [PATCH 2/4] Added link to local_example in readme. --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 105f91d..43557ef 100644 --- a/README.md +++ b/README.md @@ -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 +``` +
Local development From cec5a41b84ded3dd72f452c725fcc62766a75151 Mon Sep 17 00:00:00 2001 From: monadoid Date: Sun, 11 Jan 2026 13:08:16 -0700 Subject: [PATCH 3/4] Minimal working byob example --- examples/byob_example.py | 67 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 examples/byob_example.py diff --git a/examples/byob_example.py b/examples/byob_example.py new file mode 100644 index 0000000..65a1563 --- /dev/null +++ b/examples/byob_example.py @@ -0,0 +1,67 @@ +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) + page = await browser.new_page() + session = await client.sessions.create(model_name="openai/gpt-5-nano") + + try: + target_url = "https://news.ycombinator.com" + await page.goto(target_url, wait_until="networkidle") + await session.navigate(url=target_url) + + print("🎯 Navigated Playwright to Hacker News; Stagehand tracks the same URL.") + + print("🔄 Syncing Stagehand to the current Playwright URL:", page.url) + await session.navigate(url=page.url) + + extract_response = await session.extract( + instruction="extract the primary headline on the page", + schema={ + "type": "object", + "properties": {"headline": {"type": "string"}}, + "required": ["headline"], + }, + ) + + print("🧮 Stagehand extraction result:", extract_response.data.result) + finally: + await session.end() + await browser.close() + + +if __name__ == "__main__": + asyncio.run(main()) From fc15135951c2172813b7d762bd3865f116d14c40 Mon Sep 17 00:00:00 2001 From: monadoid Date: Sun, 11 Jan 2026 13:44:23 -0700 Subject: [PATCH 4/4] Working bring-your-own-browser-driver example interleaving playwright with stagehand. --- examples/byob_example.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/examples/byob_example.py b/examples/byob_example.py index 65a1563..1f6cb0e 100644 --- a/examples/byob_example.py +++ b/examples/byob_example.py @@ -40,20 +40,27 @@ async def main() -> None: try: target_url = "https://news.ycombinator.com" - await page.goto(target_url, wait_until="networkidle") 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("🎯 Navigated Playwright to Hacker News; Stagehand tracks the same URL.") + print("✅ Playwright clicked the first story link.") - print("🔄 Syncing Stagehand to the current Playwright URL:", page.url) + print("🔄 Syncing Stagehand to Playwright's current URL:", page.url) await session.navigate(url=page.url) extract_response = await session.extract( - instruction="extract the primary headline on the page", + instruction="extract the text of the top comment on this page", schema={ "type": "object", - "properties": {"headline": {"type": "string"}}, - "required": ["headline"], + "properties": {"comment": {"type": "string"}}, + "required": ["comment"], }, )