Skip to content
Open
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
49 changes: 49 additions & 0 deletions .github/workflows/codemode-framework-examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,52 @@ jobs:
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}

mastra:
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
name: Mastra
if: >-
github.event_name == 'push' ||
github.event.pull_request.head.repo.full_name == github.repository ||
contains(github.event.pull_request.labels.*.name, 'safe-to-test')
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"

- run: pnpm exec turbo run build --filter @browserbasehq/stagehand-codemode
- run: pnpm --filter @browserbasehq/stagehand-integrations-example-mastra typecheck
- run: pnpm --filter @browserbasehq/stagehand-integrations-example-mastra test:contract
- run: pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox pack:artifacts
- name: Detect Mastra live test credentials
id: mastra-live-credentials
env:
BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }}
BROWSERBASE_PROJECT_ID: ${{ secrets.BROWSERBASE_PROJECT_ID }}
VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }}
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
if [[ -n "$BROWSERBASE_API_KEY" && -n "$BROWSERBASE_PROJECT_ID" && -n "$OPENAI_API_KEY" ]] && \
[[ -n "$VERCEL_OIDC_TOKEN" || ( -n "$VERCEL_TEAM_ID" && -n "$VERCEL_PROJECT_ID" && -n "$VERCEL_TOKEN" ) ]]; then
echo "available=true" >> "$GITHUB_OUTPUT"
else
echo "available=false" >> "$GITHUB_OUTPUT"
fi
- name: Run Mastra live sandbox proof
if: steps.mastra-live-credentials.outputs.available == 'true'
run: pnpm --filter @browserbasehq/stagehand-integrations-example-mastra e2e
env:
STAGEHAND_SANDBOX_ARTIFACTS: ${{ github.workspace }}/packages/integrations/examples/vercel-sandbox/.artifacts
BROWSERBASE_API_KEY: ${{ secrets.BROWSERBASE_API_KEY }}
BROWSERBASE_PROJECT_ID: ${{ secrets.BROWSERBASE_PROJECT_ID }}
VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }}
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
1 change: 1 addition & 0 deletions packages/integrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,4 @@ implementation modules and an in-process arbitrary-code executor are not public

- [Vercel Sandbox](./examples/vercel-sandbox) installs the exact packed artifact inside a
Firecracker microVM and returns a framework-neutral, bearer-authenticated MCP connection.
- [Mastra](./examples/mastra) consumes that connection with one persistent remote MCP client.
68 changes: 68 additions & 0 deletions packages/integrations/examples/mastra/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Mastra with Stagehand code mode

This example gives a Mastra agent one browser tool: `code_execute`. The Stagehand code-mode MCP
server runs inside the package-installed [Vercel Sandbox example](../vercel-sandbox); Mastra connects
to its authenticated Streamable HTTP endpoint and keeps one MCP client and browser session alive for
the agent handle's lifetime.

The agent instructions come directly from the MCP tool description. The adapter does not copy the
Stagehand executor, schema, or code-mode skill, so the agent and the tool cannot drift onto different
browser APIs.

## Run the end-to-end proof

From the repository root, install dependencies, build and pack the exact Stagehand artifacts, then
run the Mastra proof:

```bash
pnpm install
pnpm exec turbo run build --filter @browserbasehq/stagehand-codemode
pnpm --filter @browserbasehq/stagehand-integrations-example-vercel-sandbox pack:artifacts
STAGEHAND_SANDBOX_ARTIFACTS="$PWD/packages/integrations/examples/vercel-sandbox/.artifacts" \
BROWSERBASE_API_KEY=<api-key> \
BROWSERBASE_PROJECT_ID=<project-id> \
VERCEL_OIDC_TOKEN=<oidc-token> \
OPENAI_API_KEY=<openai-key> \
pnpm --dir packages/integrations/examples/mastra e2e
```

For local Vercel credentials, replace `VERCEL_OIDC_TOKEN` with `VERCEL_TEAM_ID`,
`VERCEL_PROJECT_ID`, and `VERCEL_TOKEN`.

The proof exercises one live package-installed sandbox and one persistent Mastra MCP client. It:

1. invokes `code_execute` twice directly and verifies the same page ID and DOM marker;
2. makes a real Mastra model select `code_execute` and modify that existing page;
3. invokes the tool again to independently verify the model's browser-side change;
4. proves `OPENAI_API_KEY` and a host-only marker never enter model-generated code; and
5. disconnects Mastra before stopping and deleting the sandbox, emitting `PASS` only after cleanup.

Set `MASTRA_MODEL` to use a model other than `openai/gpt-5-mini`.

## Use the agent

```ts
import { createStagehandSandbox } from "@browserbasehq/stagehand-integrations-example-vercel-sandbox";

import { createStagehandAgent } from "./src/agent.js";

const connection = await createStagehandSandbox({
packageArtifactsPath: process.env.STAGEHAND_SANDBOX_ARTIFACTS!,
browserbaseApiKey: process.env.BROWSERBASE_API_KEY!,
browserbaseProjectId: process.env.BROWSERBASE_PROJECT_ID!,
});
const stagehand = await createStagehandAgent(connection);

try {
const response = await stagehand.agent.generate("Open example.com and return the page heading.", {
maxSteps: 8,
});
console.log(response.text);
} finally {
await stagehand.close();
await connection.close();
}
```

The outer application owns the sandbox connection. Disconnect the Mastra MCP client before closing
that connection so the HTTP transport can finish cleanly.
24 changes: 24 additions & 0 deletions packages/integrations/examples/mastra/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@browserbasehq/stagehand-integrations-example-mastra",
"version": "4.0.0",
"private": true,
"type": "module",
"scripts": {
"e2e": "tsx src/e2e.ts",
"test:contract": "tsx --test src/*.test.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@browserbasehq/stagehand-integrations-example-vercel-sandbox": "workspace:*",
"@mastra/core": "catalog:",
"@mastra/mcp": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:"
},
"engines": {
"node": ">=22.18.0"
}
}
76 changes: 76 additions & 0 deletions packages/integrations/examples/mastra/src/agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import test from "node:test";

import type { MCPClient } from "@mastra/mcp";

import {
loadStagehandCodeTools,
StagehandMastraSetupError,
StagehandMastraToolContractError,
} from "./agent.js";

const canonicalDescription = "# Stagehand V4 code-mode syntax\nUse the persistent page.";
const codeExecute = { description: canonicalDescription, execute: async () => undefined };

void test("accepts exactly one canonical code_execute tool", async () => {
const tools = await loadStagehandCodeTools(
fakeMcp({ toolsets: { stagehand: { code_execute: codeExecute } }, errors: {} }),
);
assert.equal(tools.code_execute, codeExecute);
});

void test("sanitizes transport discovery errors", async () => {
const secret = "https://sandbox.example.test/mcp?token=do-not-reflect";
const mcp = {
listToolsetsWithErrors: async () => {
throw new Error(secret);
},
} as unknown as MCPClient;

await assert.rejects(loadStagehandCodeTools(mcp), (error: unknown) => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
assert.ok(error instanceof StagehandMastraSetupError);
assert.equal(error.message, "Could not configure the Mastra Stagehand agent.");
assert.equal(error.message.includes(secret), false);
return true;
});
});

void test("sanitizes resolved MCP discovery errors", async () => {
const secret = "https://sandbox.example.test/mcp?token=do-not-reflect";

await assert.rejects(
loadStagehandCodeTools(
fakeMcp({
toolsets: {},
errors: { stagehand: new Error(secret) },
}),
),
(error: unknown) => {
assert.ok(error instanceof StagehandMastraSetupError);
assert.equal(error.message, "Could not configure the Mastra Stagehand agent.");
assert.equal(error.message.includes(secret), false);
return true;
},
);
});

void test("rejects non-canonical toolsets with a fixed typed error", async () => {
await assert.rejects(
loadStagehandCodeTools(
fakeMcp({
toolsets: { stagehand: { unexpected_secret_tool: codeExecute } },
errors: {},
}),
),
{
name: StagehandMastraToolContractError.name,
message: "The Stagehand MCP server returned an invalid tool contract.",
},
);
});

function fakeMcp(result: unknown): MCPClient {
return {
listToolsetsWithErrors: async () => result,
} as unknown as MCPClient;
}
114 changes: 114 additions & 0 deletions packages/integrations/examples/mastra/src/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { Agent } from "@mastra/core/agent";
import { MCPClient } from "@mastra/mcp";
import type { StagehandSandboxConnection } from "@browserbasehq/stagehand-integrations-example-vercel-sandbox";

type RemoteStagehandConnection = Pick<StagehandSandboxConnection, "url" | "token">;

export class StagehandMastraSetupError extends Error {
override readonly name = "StagehandMastraSetupError";

constructor() {
super("Could not configure the Mastra Stagehand agent.");
}
}

export class StagehandMastraToolContractError extends Error {
override readonly name = "StagehandMastraToolContractError";

constructor() {
super("The Stagehand MCP server returned an invalid tool contract.");
}
}

export function createStagehandMcpClient(connection: RemoteStagehandConnection): MCPClient {
return new MCPClient({
id: "stagehand-codemode",
servers: {
stagehand: {
url: connection.url,
fetch: async (input, init) => {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${connection.token}`);
return fetch(input, { ...init, headers });
},
},
},
});
}

export async function loadStagehandCodeTools(mcp: MCPClient) {
let discovery: Awaited<ReturnType<MCPClient["listToolsetsWithErrors"]>>;
try {
discovery = await mcp.listToolsetsWithErrors();
} catch {
throw new StagehandMastraSetupError();
}
const { toolsets, errors } = discovery;
if (Object.keys(errors).length > 0) {
throw new StagehandMastraSetupError();
}

const stagehandTools = toolsets.stagehand ?? {};
const toolNames = Object.keys(stagehandTools);
if (toolNames.length !== 1 || toolNames[0] !== "code_execute") {
throw new StagehandMastraToolContractError();
}

const codeExecute = stagehandTools.code_execute;
if (!codeExecute) {
throw new StagehandMastraToolContractError();
}

const guidance = codeExecute.description?.trim();
if (!guidance?.includes("# Stagehand V4 code-mode syntax")) {
throw new StagehandMastraToolContractError();
}

return { code_execute: codeExecute };
}

export type StagehandCodeTools = Awaited<ReturnType<typeof loadStagehandCodeTools>>;

export type StagehandAgentHandle = {
agent: Agent;
tools: StagehandCodeTools;
close: () => Promise<void>;
};

export async function createStagehandAgent(
connection: RemoteStagehandConnection,
model = process.env.MASTRA_MODEL ?? "openai/gpt-5-mini",
): Promise<StagehandAgentHandle> {
const mcp = createStagehandMcpClient(connection);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

try {
const tools = await loadStagehandCodeTools(mcp);
const instructions = tools.code_execute.description;
if (!instructions) {
throw new StagehandMastraToolContractError();
}

const agent = new Agent({
id: "stagehand-browser-agent",
name: "Stagehand browser agent",
instructions,
model,
tools,
});

return {
agent,
tools,
close: () => mcp.disconnect(),
};
} catch (error) {
await mcp.disconnect().catch(() => undefined);
if (
error instanceof StagehandMastraSetupError ||
error instanceof StagehandMastraToolContractError
) {
throw error;
}
throw new StagehandMastraSetupError();
}
}
Loading
Loading