Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
35 changes: 35 additions & 0 deletions .github/workflows/codemode-framework-examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,38 @@ jobs:
env:
CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
STAGEHAND_BROWSER: local

crewai:
name: CrewAI
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0

- uses: ./.github/actions/setup-node-pnpm
with:
use-prebuilt-artifacts: "false"

- uses: ./.github/actions/setup-chrome-verified
id: setup-chrome

- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.12"
cache: pip
cache-dependency-path: packages/integrations/examples/crewai/requirements.txt

- run: pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations
- run: python -m pip install -r packages/integrations/examples/crewai/requirements.txt
- run: >-
python -m py_compile
packages/integrations/examples/shared/modal_stdio_bridge.py
packages/integrations/examples/shared/test_modal_stdio_bridge.py
packages/integrations/examples/crewai/agent.py
packages/integrations/examples/crewai/smoke.py
- run: python packages/integrations/examples/shared/test_modal_stdio_bridge.py
- run: python packages/integrations/examples/crewai/smoke.py
env:
CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }}
STAGEHAND_BROWSER: local
1 change: 1 addition & 0 deletions packages/integrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ The process stays alive across calls and closes when its input stream ends. `SIG

- [Vercel AI SDK](./examples/vercel) launches the stdio server through the AI SDK MCP client and keeps one process alive for the complete agent run.
- [Mastra](./examples/mastra) discovers the canonical MCP toolset once and reuses one client and browser for the complete agent run.
- [CrewAI](./examples/crewai) keeps its context-managed MCP adapter open across every tool call in one crew execution.

### Configuration

Expand Down
90 changes: 90 additions & 0 deletions packages/integrations/examples/crewai/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# CrewAI + sandboxed Stagehand code mode

Use Stagehand code mode as one CrewAI tool without running generated JavaScript on the agent host.
CrewAI still connects to a local stdio child, but that child is a small trusted bridge. The bridge
starts `stdio-server.mjs` as the primary process in a [Modal Sandbox](https://modal.com/docs/guide/sandboxes)
and forwards MCP bytes unchanged:

```text
CrewAI -> local stdio -> trusted bridge -> Modal Sandbox -> Stagehand MCP
`-> generated JavaScript
```

The agent's model credential remains in the host process. The bridge gives the sandbox only
`STAGEHAND_BROWSER=browserbase`, `BROWSERBASE_API_KEY`, optional `BROWSERBASE_PROJECT_ID`, and the
optional Stagehand-specific `STAGEHAND_MODEL_NAME` and `STAGEHAND_MODEL_API_KEY` pair. Set that pair
only when generated code needs AI-backed Stagehand methods such as `act` or `extract`.

> The proposed `ghcr.io/browserbase/stagehand-codemode` image is not published yet. Until it is,
> maintainers can set `STAGEHAND_CODEMODE_MODAL_IMAGE_ID` to an image built from the same Stagehand
> commit. Once published, pin `STAGEHAND_CODEMODE_IMAGE` to a version or digest instead of a mutable
> `latest` tag.

## Setup

Create a Python 3.12 environment and install the example:

```bash
python3.12 -m venv .venv
. .venv/bin/activate
python -m pip install -r packages/integrations/examples/crewai/requirements.txt
```

Configure Modal, Browserbase, and an immutable code-mode image. Configure the model provider key
required by `DEFAULT_STAGEHAND_LLM` (`openai/gpt-5-mini`) separately in the host environment:

```bash
export MODAL_TOKEN_ID="..."
export MODAL_TOKEN_SECRET="..."
export BROWSERBASE_API_KEY="..."
export STAGEHAND_CODEMODE_IMAGE="ghcr.io/browserbase/stagehand-codemode:<version-or-digest>"
export OPENAI_API_KEY="..."
```

`BROWSERBASE_PROJECT_ID` is optional. Modal can also use its normal local profile instead of token
environment variables.

## Run an agent

Run from `packages/integrations/examples/crewai`:

```python
from agent import run_stagehand_agent

result = run_stagehand_agent(
"Open https://example.com and return its title and URL."
)
print(result)
```

`run_stagehand_agent` keeps one context-managed `MCPServerAdapter` open for the complete `kickoff`.
That lifecycle matters: every `code_execute` call reaches the same MCP process, sandbox, and browser.
Leaving the context sends EOF to the MCP, gives Stagehand a chance to close the browser, and then
terminates the sandbox if it is still running.

## Sandbox policy

The bridge defaults to a 10-minute hard timeout, a 5-minute idle timeout, and outbound access only
to `*.browserbase.com`. Adjust them only when the task requires it:

```bash
export STAGEHAND_CODEMODE_TIMEOUT_SECONDS=900
export STAGEHAND_CODEMODE_IDLE_TIMEOUT_SECONDS=300
export STAGEHAND_CODEMODE_OUTBOUND_DOMAINS="*.browserbase.com,api.example.com"
```

Each extra domain is reachable by arbitrary generated JavaScript, so keep the list task-specific.
The Browserbase credential is intentionally present inside the sandbox and should be scoped and
rotated accordingly. The hard timeout is the final cleanup backstop if generated synchronous code
cannot be interrupted cooperatively.

## Local CI smoke

The deterministic smoke starts the MCP directly on the CI runner with a local browser. It is useful
for secret-free protocol and persistence coverage, but it is not the recommended boundary for
production or untrusted prompts:

```bash
pnpm turbo run build --filter @browserbasehq/stagehand-integrations
python packages/integrations/examples/crewai/smoke.py
```
105 changes: 105 additions & 0 deletions packages/integrations/examples/crewai/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
from __future__ import annotations

import os
import sys
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from functools import lru_cache
from pathlib import Path
from typing import Any

from crewai.tools import BaseTool
from crewai_tools import MCPServerAdapter

from crewai import Agent
from mcp import StdioServerParameters

REPOSITORY_ROOT = Path(__file__).resolve().parents[4]
MODAL_STDIO_BRIDGE_PATH = (
REPOSITORY_ROOT / "packages/integrations/examples/shared/modal_stdio_bridge.py"
)
SKILL_PATH = REPOSITORY_ROOT / "packages/integrations/codemode/SKILL.md"
DEFAULT_STAGEHAND_LLM = "openai/gpt-5-mini"

BRIDGE_ENV_KEYS = (
"PATH",
"HOME",
"TMPDIR",
"LANG",
"LC_ALL",
"SSL_CERT_FILE",
"REQUESTS_CA_BUNDLE",
"MODAL_TOKEN_ID",
"MODAL_TOKEN_SECRET",
"MODAL_PROFILE",
"BROWSERBASE_API_KEY",
"BROWSERBASE_PROJECT_ID",
"STAGEHAND_MODEL_NAME",
"STAGEHAND_MODEL_API_KEY",
"STAGEHAND_CODEMODE_IMAGE",
"STAGEHAND_CODEMODE_MODAL_IMAGE_ID",
"STAGEHAND_CODEMODE_MODAL_APP",
"STAGEHAND_CODEMODE_ENTRYPOINT",
"STAGEHAND_CODEMODE_TIMEOUT_SECONDS",
"STAGEHAND_CODEMODE_IDLE_TIMEOUT_SECONDS",
"STAGEHAND_CODEMODE_OUTBOUND_DOMAINS",
)


@lru_cache(maxsize=1)
def load_stagehand_codemode_skill() -> str:
return SKILL_PATH.read_text(encoding="utf-8").strip()


def modal_bridge_env(overrides: dict[str, str] | None = None) -> dict[str, str]:
"""Build the trusted proxy environment without forwarding agent model keys."""
child_env = {
key: value for key in BRIDGE_ENV_KEYS if (value := os.environ.get(key)) is not None
}
if overrides:
child_env.update({key: value for key, value in overrides.items() if key in BRIDGE_ENV_KEYS})
return child_env


@contextmanager
def stagehand_code_tools(
env: dict[str, str] | None = None,
) -> Iterator[list[BaseTool]]:
"""Keep one sandboxed Stagehand MCP connected for a complete CrewAI run."""
if not MODAL_STDIO_BRIDGE_PATH.is_file():
raise FileNotFoundError(
f"Stagehand Modal stdio bridge not found: {MODAL_STDIO_BRIDGE_PATH}"
)

parameters = StdioServerParameters(
command=sys.executable,
args=[str(MODAL_STDIO_BRIDGE_PATH)],
cwd=REPOSITORY_ROOT,
env=modal_bridge_env(env),
)
with MCPServerAdapter(parameters, connect_timeout=600) as discovered_tools:
tools = list(discovered_tools)
names = [tool.name for tool in tools]
if names != ["code_execute"]:
raise RuntimeError(f"Expected only code_execute from Stagehand MCP, got {names!r}.")
yield tools


def build_stagehand_agent(
tools: Sequence[BaseTool],
llm: str | Any = DEFAULT_STAGEHAND_LLM,
) -> Agent:
return Agent(
role="Stagehand browser agent",
goal="Complete browser tasks by writing compact, correct Stagehand V4 JavaScript.",
backstory=load_stagehand_codemode_skill(),
llm=llm,
tools=list(tools),
max_iter=8,
verbose=False,
)


def run_stagehand_agent(prompt: str, llm: str | Any = DEFAULT_STAGEHAND_LLM) -> str:
with stagehand_code_tools() as tools:
return str(build_stagehand_agent(tools, llm).kickoff(prompt))
3 changes: 3 additions & 0 deletions packages/integrations/examples/crewai/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
crewai>=1.15.9,<2
crewai-tools[mcp]>=1.15.9,<2
modal>=1.5.3,<2
119 changes: 119 additions & 0 deletions packages/integrations/examples/crewai/smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
from __future__ import annotations

import json
import os
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any

from crewai.tools import BaseTool
from crewai_tools import MCPServerAdapter

from agent import (
REPOSITORY_ROOT,
build_stagehand_agent,
load_stagehand_codemode_skill,
)
from mcp import StdioServerParameters

LOCAL_STDIO_SERVER_PATH = REPOSITORY_ROOT / "packages/integrations/dist/codemode/stdio-server.mjs"


@contextmanager
def local_stagehand_code_tools_for_ci() -> Iterator[list[BaseTool]]:
"""Launch the MCP directly only for the credential-free local CI smoke."""
if not LOCAL_STDIO_SERVER_PATH.is_file():
raise FileNotFoundError(
"Build @browserbasehq/stagehand-integrations before running the smoke"
)
child_env = {
"PATH": os.environ.get("PATH", ""),
"STAGEHAND_BROWSER": "local",
}
# Stagehand's local launcher uses CI to add Chrome's --no-sandbox flag.
for name in ("HOME", "TMPDIR", "CHROME_PATH", "CI"):
if value := os.environ.get(name):
child_env[name] = value
parameters = StdioServerParameters(
command="node",
args=[str(LOCAL_STDIO_SERVER_PATH)],
cwd=Path(REPOSITORY_ROOT),
env=child_env,
)
with MCPServerAdapter(parameters) as discovered_tools:
yield list(discovered_tools)


def successful_result(raw_result: Any) -> dict[str, Any]:
result = json.loads(str(raw_result))
assert result["ok"] is True, result
return result


def main() -> None:
os.environ.setdefault("OPENAI_API_KEY", "smoke-only-placeholder")

with local_stagehand_code_tools_for_ci() as tools:
assert [tool.name for tool in tools] == ["code_execute"]
assert "Stagehand V4 code-mode syntax" in tools[0].description
assert "stagehand.extract" in load_stagehand_codemode_skill()

agent = build_stagehand_agent(tools)
code_execute = agent.tools[0]

first = successful_result(
code_execute.run(
code="""
await page.goto("https://example.com", { waitUntil: "domcontentloaded" });
await page.evaluate(() => {
document.documentElement.dataset.crewaiStagehandSession = "persisted";
});
return {
pageId: page.pageId,
title: await page.title(),
marker: await page.evaluate(
() => document.documentElement.dataset.crewaiStagehandSession,
),
};
"""
)
)
second = successful_result(
code_execute.run(
code="""
return {
pageId: page.pageId,
title: await page.title(),
marker: await page.evaluate(
() => document.documentElement.dataset.crewaiStagehandSession,
),
};
"""
)
)

first_value = first["value"]
second_value = second["value"]
assert first_value["title"] == "Example Domain"
assert second_value["title"] == "Example Domain"
assert second_value["marker"] == "persisted"
assert second_value["pageId"] == first_value["pageId"]

print(
"CrewAI local CI-only Stagehand MCP persistence PASS:",
json.dumps(
{
"browser": "local",
"tool": "code_execute",
"pageId": second_value["pageId"],
"title": second_value["title"],
"marker": second_value["marker"],
},
sort_keys=True,
),
)


if __name__ == "__main__":
main()
Loading
Loading