From a7c58d92b22d2bfa9374aec21c985e0b644fe4df Mon Sep 17 00:00:00 2001 From: Alyssa Maruyama Date: Sat, 26 Sep 2026 15:42:26 -0700 Subject: [PATCH 1/2] Update the functions sdk to use stagehand (with secrets) in the template --- README.md | 85 ++++++++---- src/cli/init/index.ts | 121 +++++++++++++++--- src/cli/init/templates/.env.template | 3 + .../templates/pnpm-workspace.yaml.template | 4 + src/cli/init/templates/stagehand.ts.template | 54 ++++++++ .../templates/starter-function.ts.template | 88 +++++-------- tests/integration/cli/init.test.ts | 43 ++++++- 7 files changed, 301 insertions(+), 97 deletions(-) create mode 100644 src/cli/init/templates/pnpm-workspace.yaml.template create mode 100644 src/cli/init/templates/stagehand.ts.template diff --git a/README.md b/README.md index d268398..8556080 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,17 @@ pnpm dlx @browserbasehq/sdk-functions init my-project cd my-project ``` -Add your Browserbase API key to `.env`: +Add your Browserbase and model API keys to `.env`: ```sh BROWSERBASE_API_KEY=your_api_key_here +OPENAI_API_KEY=your_openai_api_key_here +``` + +The starter function uses [Stagehand](https://docs.stagehand.dev), which needs its extension in the browser session. Upload the extension one time, then paste the returned `id` into `stagehandSessionConfig` in `stagehand.ts`: + +```sh +browse cloud extensions upload node_modules/@browserbasehq/stagehand/dist/assets/stagehand-extension.zip ``` Start the local development server: @@ -45,6 +52,8 @@ When ready, publish to Browserbase: pnpm bb publish index.ts ``` +Then [attach your API keys as secrets](#secrets) to the published function. + ## Usage ### Basic Function @@ -59,25 +68,33 @@ defineFn("hello-world", async () => { ### Browser Automation -Every function receives a `context` with a managed browser session. Connect to it with Playwright: +Every function receives a `context` with a managed browser session. Drive it with Stagehand through the `withStagehand` helper that `bb init` creates in `stagehand.ts`: ```ts import { defineFn } from "@browserbasehq/sdk-functions"; -import { chromium } from "playwright-core"; +import { z } from "zod/v4"; -defineFn("scrape-titles", async (context) => { - const browser = await chromium.connectOverCDP(context.session.connectUrl); - const page = browser.contexts()[0]!.pages()[0]!; +import { stagehandSessionConfig, withStagehand } from "./stagehand.js"; - await page.goto("https://news.ycombinator.com"); - const titles = await page.$$eval(".titleline > a", (els) => - els.slice(0, 5).map((el) => el.textContent), - ); +defineFn( + "scrape-titles", + (context) => + withStagehand(context, async ({ stagehand, page }) => { + await page.goto("https://news.ycombinator.com"); - return { titles }; -}); + const { data } = await stagehand.extract( + "Extract the titles of the top 5 stories", + z.object({ titles: z.array(z.string()).max(5) }), + ); + + return { titles: data.titles }; + }), + { sessionConfig: stagehandSessionConfig }, +); ``` +`withStagehand` attaches Stagehand to the function's session with `browserbase.connect()`, and closes Stagehand when your code finishes. Browserbase releases the session when the invocation ends. Keep `stagehandSessionConfig` in every function's `sessionConfig`. It adds the Stagehand extension to the session. + ### Parameter Validation Use [Zod](https://zod.dev) schemas to validate parameters passed to your function: @@ -106,25 +123,46 @@ Pass `sessionConfig` to customize the browser session (uses the same options as ```ts import { defineFn } from "@browserbasehq/sdk-functions"; -import { chromium } from "playwright-core"; +import { z } from "zod/v4"; + +import { stagehandSessionConfig, withStagehand } from "./stagehand.js"; defineFn( "stealth-scraper", - async (context) => { - const browser = await chromium.connectOverCDP(context.session.connectUrl); - const page = browser.contexts()[0]!.pages()[0]!; + (context) => + withStagehand(context, async ({ stagehand, page }) => { + await page.goto("https://example.com"); - await page.goto("https://example.com"); - return { content: await page.textContent("body") }; - }, + const { data } = await stagehand.extract( + "Extract the main text of the page", + z.object({ content: z.string() }), + ); + + return { content: data.content }; + }), { sessionConfig: { + ...stagehandSessionConfig, browserSettings: { advancedStealth: true }, }, }, ); ``` +### Secrets + +Keep API keys in encrypted project secrets. Each secret attached to a function is available as `context.secrets[name]`. The `withStagehand` helper reads `BROWSERBASE_API_KEY` and `OPENAI_API_KEY` this way. + +Create the secrets and attach them to the published function with the [`browse` CLI](https://www.npmjs.com/package/browse): + +```sh +browse cloud secrets create BROWSERBASE_API_KEY --env BROWSERBASE_API_KEY +browse cloud secrets create OPENAI_API_KEY --env OPENAI_API_KEY +browse functions secrets attach +``` + +Use the function ID from `builtFunctions[].id` in the publish output. The local development server doesn't pass secrets, so `withStagehand` falls back to the values in `.env`. + ## CLI Reference The `bb` CLI is included with the package. @@ -187,11 +225,12 @@ Options: ## Configuration -Set your Browserbase API key as an environment variable or in a `.env` file: +Set variables in your environment or in a `.env` file for the CLI and the local development server. Deployed functions read them from [secrets](#secrets) instead. -| Variable | Required | Description | -| --------------------- | -------- | ------------------------ | -| `BROWSERBASE_API_KEY` | Yes | Your Browserbase API key | +| Variable | Required | Description | +| --------------------- | ------------------------------ | ------------------------------------------- | +| `BROWSERBASE_API_KEY` | Yes | Your Browserbase API key | +| `OPENAI_API_KEY` | For the `withStagehand` helper | Your OpenAI API key for the Stagehand model | Get your API key from [browserbase.com](https://browserbase.com). diff --git a/src/cli/init/index.ts b/src/cli/init/index.ts index 4e3373b..511b6b2 100644 --- a/src/cli/init/index.ts +++ b/src/cli/init/index.ts @@ -73,6 +73,9 @@ export async function init(options: InitOptions) { // Step 5: Detect and update package manager const packageManager = detectPackageManager(options.packageManager); updatePackageManager(targetDir, packageManager); + if (packageManager === "pnpm") { + createPnpmWorkspaceFile(targetDir); + } // Step 6: Install dependencies console.log(chalk.gray("Installing dependencies...")); @@ -108,20 +111,38 @@ export async function init(options: InitOptions) { console.log(chalk.gray("1. Navigate to your project:")); console.log(chalk.white(` cd ${options.projectName}`)); console.log( - chalk.gray("2. Add your Browserbase API key and project ID to .env"), + chalk.gray("2. Add your Browserbase and OpenAI API keys to .env"), + ); + console.log( + chalk.gray( + "3. Upload the Stagehand extension, then paste its ID into stagehand.ts:", + ), ); - console.log(chalk.gray("3. Run your function locally:")); + console.log( + chalk.white( + " browse cloud extensions upload node_modules/@browserbasehq/stagehand/dist/assets/stagehand-extension.zip", + ), + ); + console.log(chalk.gray("4. Run your function locally:")); console.log( chalk.white( ` ${packageManager === "pnpm" ? "pnpm" : "npx"} bb dev index.ts`, ), ); - console.log(chalk.gray("4. When ready, publish your function:")); + console.log(chalk.gray("5. When ready, publish your function:")); console.log( chalk.white( ` ${packageManager === "pnpm" ? "pnpm" : "npx"} bb publish index.ts`, ), ); + console.log( + chalk.gray( + "6. Create BROWSERBASE_API_KEY and OPENAI_API_KEY project secrets and attach them to the function:", + ), + ); + console.log( + chalk.white(" browse functions secrets attach "), + ); console.log(""); console.log(chalk.gray("Learn more at https://browserbase.com/docs")); } catch (error) { @@ -192,6 +213,9 @@ function updatePackageManager( // Add "type": "module" to support ES modules packageJson.type = "module"; + // pnpm 11 init adds devEngines.packageManager. npm rejects it, and it conflicts with packageManager. + delete packageJson.devEngines; + writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); console.log( chalk.green(`✓ Package manager set to ${packageJson.packageManager}`), @@ -208,29 +232,77 @@ function installDependencies( // Install regular dependencies console.log(chalk.gray(" Installing @browserbasehq/sdk-functions...")); - execSync(`${installCmd} @browserbasehq/sdk-functions`, { - cwd: targetDir, - stdio: "pipe", - }); + runInstallCommand(`${installCmd} @browserbasehq/sdk-functions`, targetDir); - console.log(chalk.gray(" Installing playwright-core...")); - execSync(`${installCmd} playwright-core`, { - cwd: targetDir, - stdio: "pipe", - }); + console.log(chalk.gray(" Installing @browserbasehq/stagehand...")); + runInstallCommand(`${installCmd} @browserbasehq/stagehand`, targetDir); + // Two zod copies make schemas passed to Stagehand fail type checks, so match Stagehand's version. + const zodVersion = readStagehandZodVersion(targetDir); console.log(chalk.gray(" Installing zod...")); - execSync(`${installCmd} zod`, { - cwd: targetDir, - stdio: "pipe", - }); + runInstallCommand( + `${installCmd} ${zodVersion ? `zod@${zodVersion}` : "zod"}`, + targetDir, + ); // Install dev dependencies console.log(chalk.gray(" Installing TypeScript and type definitions...")); - execSync(`${installDevCmd} typescript @types/node`, { - cwd: targetDir, - stdio: "pipe", - }); + runInstallCommand(`${installDevCmd} typescript @types/node`, targetDir); +} + +function readStagehandZodVersion(targetDir: string): string | undefined { + try { + const stagehandPackageJson = JSON.parse( + readFileSync( + join( + targetDir, + "node_modules", + "@browserbasehq", + "stagehand", + "package.json", + ), + "utf-8", + ), + ); + const version = stagehandPackageJson.dependencies?.zod; + return typeof version === "string" ? version : undefined; + } catch { + return undefined; + } +} + +function runInstallCommand(command: string, cwd: string) { + try { + execSync(command, { cwd, stdio: "pipe" }); + } catch (error) { + // execSync's message has only stderr. The package manager often writes the real error to stdout. + const { stdout, stderr } = error as { stdout?: Buffer; stderr?: Buffer }; + const output = [stdout, stderr] + .map((stream) => stream?.toString().trim()) + .filter(Boolean) + .join("\n"); + throw new Error( + `Command failed: ${command}${output ? `\n${output}` : ""}`, + { + cause: error, + }, + ); + } +} + +function createPnpmWorkspaceFile(targetDir: string) { + const workspacePath = join(targetDir, "pnpm-workspace.yaml"); + if (!existsSync(workspacePath)) { + const templatePath = join( + __dirname, + "templates", + "pnpm-workspace.yaml.template", + ); + copyFileSync(templatePath, workspacePath); + console.log(chalk.green("✓ pnpm-workspace.yaml file created")); + } else { + console.log(chalk.yellow("✓ pnpm-workspace.yaml file already exists")); + } } function createEnvFile(targetDir: string) { @@ -268,6 +340,15 @@ function createStarterFunction(targetDir: string) { } else { console.log(chalk.yellow("✓ index.ts already exists")); } + + const helperPath = join(targetDir, "stagehand.ts"); + if (!existsSync(helperPath)) { + const templatePath = join(__dirname, "templates", "stagehand.ts.template"); + copyFileSync(templatePath, helperPath); + console.log(chalk.green("✓ Stagehand helper created (stagehand.ts)")); + } else { + console.log(chalk.yellow("✓ stagehand.ts already exists")); + } } function updateTsConfig(targetDir: string) { diff --git a/src/cli/init/templates/.env.template b/src/cli/init/templates/.env.template index e7e6194..ccd3685 100644 --- a/src/cli/init/templates/.env.template +++ b/src/cli/init/templates/.env.template @@ -3,3 +3,6 @@ # Your Browserbase API key BROWSERBASE_API_KEY=your_api_key_here + +# Your model API key for Stagehand. Using OpenAI for this example +OPENAI_API_KEY=your_openai_api_key_here diff --git a/src/cli/init/templates/pnpm-workspace.yaml.template b/src/cli/init/templates/pnpm-workspace.yaml.template new file mode 100644 index 0000000..2570873 --- /dev/null +++ b/src/cli/init/templates/pnpm-workspace.yaml.template @@ -0,0 +1,4 @@ +# pnpm settings for this project. This file does not make the project a workspace. +# pnpm 11 and later fail installs unless esbuild's build script is allowed. +allowBuilds: + esbuild: true diff --git a/src/cli/init/templates/stagehand.ts.template b/src/cli/init/templates/stagehand.ts.template new file mode 100644 index 0000000..610ef79 --- /dev/null +++ b/src/cli/init/templates/stagehand.ts.template @@ -0,0 +1,54 @@ +import { browserbase, type Page, Stagehand } from "@browserbasehq/stagehand"; + +// Upload the Stagehand extension once, then paste its ID here: +// browse cloud extensions upload node_modules/@browserbasehq/stagehand/dist/assets/stagehand-extension.zip +// The build reads sessionConfig into the Function manifest, so use a literal value. +export const stagehandSessionConfig = { + extensionId: "your-extension-id", +}; + +interface StagehandContext { + session: { id: string }; + secrets?: Record; +} + +// Deployed Functions read attached secrets. The local dev server has none, so fall back to .env. +function readSecret(context: StagehandContext, name: string): string { + const value = context.secrets?.[name] ?? process.env[name]; + if (!value) { + throw new Error( + `Secret "${name}" is not set. Attach it to the function or add it to .env.`, + ); + } + return value; +} + +export async function withStagehand( + context: StagehandContext, + run: (tools: { stagehand: Stagehand; page: Page }) => Promise, +): Promise { + // Attach to the session that Browserbase created for this invocation. + // Don't call browser.close(): it releases the session, and Browserbase releases it when the invocation ends. + const browser = await browserbase.connect({ + apiKey: readSecret(context, "BROWSERBASE_API_KEY"), + sessionId: context.session.id, + }); + + const stagehand = await Stagehand.create({ + browser, + model: { + modelName: "openai/gpt-5.6-sol", + apiKey: readSecret(context, "OPENAI_API_KEY"), + }, + }); + + try { + const page = await browser.context.activePage(); + if (!page) { + throw new Error("Stagehand initialized without an active page"); + } + return await run({ stagehand, page }); + } finally { + await stagehand.close(); + } +} diff --git a/src/cli/init/templates/starter-function.ts.template b/src/cli/init/templates/starter-function.ts.template index fd73548..af8818f 100644 --- a/src/cli/init/templates/starter-function.ts.template +++ b/src/cli/init/templates/starter-function.ts.template @@ -1,61 +1,43 @@ import { defineFn } from "@browserbasehq/sdk-functions"; -import { chromium } from "playwright-core"; +import { z } from "zod/v4"; + +import { stagehandSessionConfig, withStagehand } from "./stagehand.js"; // This is your first Browserbase function! // You can run it locally with: bb dev index.ts // Once ready, publish it with: bb publish index.ts -type HNSubmission = { - title: string | null; - url: string | null; - rank: number; -}; - -defineFn("my-function", async (context) => { - const { session } = context; - - console.log("Connecting to browser session:", session.id); - - // Connect to the browser instance - const browser = await chromium.connectOverCDP(session.connectUrl); - const browserContext = browser.contexts()[0]!; - const page = browserContext.pages()[0]!; - - // Navigate to Hacker News - console.log("Navigating to Hacker News..."); - await page.goto("https://news.ycombinator.com"); - - // Wait for the content to load - await page.waitForSelector(".athing", { timeout: 30000 }); - - // Extract the first three submission titles - const titles = await page.evaluate(() => { - const results: HNSubmission[] = []; - - document.querySelectorAll(".athing").forEach((submission, idx) => { - if (idx >= 3) return; // only return 3 - - const titleElement = submission.querySelector(".titleline > a"); - - if (titleElement) { - results.push({ - title: titleElement.textContent ?? null, - url: titleElement.getAttribute("href"), - rank: idx + 1, - }); - } - }); - - return results; - }); - - console.log(`Successfully extracted ${titles.length} titles`); - - // Return the results - return { - message: "Successfully fetched top Hacker News stories", - timestamp: new Date().toISOString(), - results: titles, - }; +const HNStories = z.object({ + stories: z + .array( + z.object({ + rank: z.number(), + title: z.string(), + url: z.string(), + }), + ) + .max(3), }); +defineFn( + "my-function", + (context) => + withStagehand(context, async ({ stagehand, page }) => { + console.log("Navigating to Hacker News..."); + await page.goto("https://news.ycombinator.com"); + + const { data } = await stagehand.extract( + "Extract the top 3 stories with their rank, title, and link URL.", + HNStories, + ); + + console.log(`Successfully extracted ${data.stories.length} stories`); + + return { + message: "Successfully fetched top Hacker News stories", + timestamp: new Date().toISOString(), + results: data.stories, + }; + }), + { sessionConfig: stagehandSessionConfig }, +); diff --git a/tests/integration/cli/init.test.ts b/tests/integration/cli/init.test.ts index c0860b7..6e93227 100644 --- a/tests/integration/cli/init.test.ts +++ b/tests/integration/cli/init.test.ts @@ -42,6 +42,15 @@ describe("Init Command", () => { existsSync(join(projectDir, "index.ts")), "index.ts should exist", ); + assert.ok( + existsSync(join(projectDir, "stagehand.ts")), + "stagehand.ts should exist", + ); + assert.doesNotMatch( + readFileSync(join(projectDir, "stagehand.ts"), "utf-8"), + /await browser\.close\(\)/, + "stagehand.ts should not release the Function's session with browser.close()", + ); assert.ok(existsSync(join(projectDir, ".env")), ".env should exist"); assert.ok( existsSync(join(projectDir, ".gitignore")), @@ -55,6 +64,11 @@ describe("Init Command", () => { existsSync(join(projectDir, ".git")), ".git directory should exist", ); + assert.match( + readFileSync(join(projectDir, "pnpm-workspace.yaml"), "utf-8"), + /allowBuilds:\n {2}esbuild: true/, + "pnpm-workspace.yaml should allow the esbuild build script", + ); }); it("package.json has correct contents", () => { @@ -68,6 +82,11 @@ describe("Init Command", () => { const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); assert.equal(pkg.type, "module", 'Should have "type": "module"'); + assert.equal( + pkg.devEngines, + undefined, + "Should not have devEngines, because npm rejects devEngines.packageManager set to pnpm", + ); // Check dependencies include expected packages const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }; @@ -75,8 +94,30 @@ describe("Init Command", () => { allDeps["@browserbasehq/sdk-functions"], "Should depend on @browserbasehq/sdk-functions", ); - assert.ok(allDeps["playwright-core"], "Should depend on playwright-core"); + assert.ok( + allDeps["@browserbasehq/stagehand"], + "Should depend on @browserbasehq/stagehand", + ); + + // zod must match Stagehand's version, so schemas passed to Stagehand type-check. + const stagehandPkg = JSON.parse( + readFileSync( + join( + dir, + projectName, + "node_modules", + "@browserbasehq", + "stagehand", + "package.json", + ), + "utf-8", + ), + ); assert.ok(allDeps["zod"], "Should depend on zod"); + assert.ok( + String(allDeps["zod"]).includes(stagehandPkg.dependencies.zod), + `zod (${allDeps["zod"]}) should match Stagehand's zod (${stagehandPkg.dependencies.zod})`, + ); }); it("rejects invalid project names", () => { From 756605a5cc244cb383c5a8a8f77a1ab7840af23c Mon Sep 17 00:00:00 2001 From: Alyssa Maruyama Date: Mon, 28 Sep 2026 21:28:09 -0700 Subject: [PATCH 2/2] Use simplified template in functions sdk --- README.md | 96 ++++++++++--------- src/cli/init/index.ts | 19 +--- src/cli/init/templates/.env.template | 6 +- .../templates/pnpm-workspace.yaml.template | 3 +- src/cli/init/templates/stagehand.ts.template | 54 ----------- .../templates/starter-function.ts.template | 63 ++++++------ tests/integration/cli/init.test.ts | 14 +-- 7 files changed, 99 insertions(+), 156 deletions(-) delete mode 100644 src/cli/init/templates/stagehand.ts.template diff --git a/README.md b/README.md index 8556080..f3a84f6 100644 --- a/README.md +++ b/README.md @@ -27,14 +27,13 @@ pnpm dlx @browserbasehq/sdk-functions init my-project cd my-project ``` -Add your Browserbase and model API keys to `.env`: +Add your Browserbase API key to `.env`: ```sh BROWSERBASE_API_KEY=your_api_key_here -OPENAI_API_KEY=your_openai_api_key_here ``` -The starter function uses [Stagehand](https://docs.stagehand.dev), which needs its extension in the browser session. Upload the extension one time, then paste the returned `id` into `stagehandSessionConfig` in `stagehand.ts`: +The starter function uses [Stagehand](https://docs.stagehand.dev), which needs its extension in the browser session. Upload the extension then paste the returned `id` into `extensionId` in `index.ts`: ```sh browse cloud extensions upload node_modules/@browserbasehq/stagehand/dist/assets/stagehand-extension.zip @@ -52,7 +51,7 @@ When ready, publish to Browserbase: pnpm bb publish index.ts ``` -Then [attach your API keys as secrets](#secrets) to the published function. +Then [attach your API key as a secret](#secrets) to the published function. ## Usage @@ -68,33 +67,40 @@ defineFn("hello-world", async () => { ### Browser Automation -Every function receives a `context` with a managed browser session. Drive it with Stagehand through the `withStagehand` helper that `bb init` creates in `stagehand.ts`: +Every function receives a `context` with a managed browser session. Connect [Stagehand](https://docs.stagehand.dev) to it by session ID: ```ts import { defineFn } from "@browserbasehq/sdk-functions"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; import { z } from "zod/v4"; -import { stagehandSessionConfig, withStagehand } from "./stagehand.js"; - defineFn( "scrape-titles", - (context) => - withStagehand(context, async ({ stagehand, page }) => { - await page.goto("https://news.ycombinator.com"); - - const { data } = await stagehand.extract( - "Extract the titles of the top 5 stories", - z.object({ titles: z.array(z.string()).max(5) }), - ); - - return { titles: data.titles }; - }), - { sessionConfig: stagehandSessionConfig }, + async (context) => { + const browser = await browserbase.connect({ + // The local dev server has no secrets, so fall back to .env. + apiKey: + context.secrets.BROWSERBASE_API_KEY ?? process.env.BROWSERBASE_API_KEY!, + sessionId: context.session.id, + }); + // In this example, Stagehand uses the Model Gateway where Browserbase charges for the tokens + const stagehand = await Stagehand.create({ browser }); + const page = (await browser.context.activePage())!; + + await page.goto("https://news.ycombinator.com"); + const { data } = await stagehand.extract( + "Extract the titles of the top 5 stories", + z.object({ titles: z.array(z.string()).max(5) }), + ); + + await stagehand.close(); + return { titles: data.titles }; + }, + // The ID from `browse cloud extensions upload`. Stagehand needs its extension in the session. + { sessionConfig: { extensionId: "your-extension-id" } }, ); ``` -`withStagehand` attaches Stagehand to the function's session with `browserbase.connect()`, and closes Stagehand when your code finishes. Browserbase releases the session when the invocation ends. Keep `stagehandSessionConfig` in every function's `sessionConfig`. It adds the Stagehand extension to the session. - ### Parameter Validation Use [Zod](https://zod.dev) schemas to validate parameters passed to your function: @@ -123,26 +129,32 @@ Pass `sessionConfig` to customize the browser session (uses the same options as ```ts import { defineFn } from "@browserbasehq/sdk-functions"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; import { z } from "zod/v4"; -import { stagehandSessionConfig, withStagehand } from "./stagehand.js"; - defineFn( "stealth-scraper", - (context) => - withStagehand(context, async ({ stagehand, page }) => { - await page.goto("https://example.com"); - - const { data } = await stagehand.extract( - "Extract the main text of the page", - z.object({ content: z.string() }), - ); - - return { content: data.content }; - }), + async (context) => { + const browser = await browserbase.connect({ + apiKey: + context.secrets.BROWSERBASE_API_KEY ?? process.env.BROWSERBASE_API_KEY!, + sessionId: context.session.id, + }); + const stagehand = await Stagehand.create({ browser }); + const page = (await browser.context.activePage())!; + + await page.goto("https://example.com"); + const { data } = await stagehand.extract( + "Extract the main text of the page", + z.object({ content: z.string() }), + ); + + await stagehand.close(); + return { content: data.content }; + }, { sessionConfig: { - ...stagehandSessionConfig, + extensionId: "your-extension-id", // Stagehand's extension browserSettings: { advancedStealth: true }, }, }, @@ -151,17 +163,16 @@ defineFn( ### Secrets -Keep API keys in encrypted project secrets. Each secret attached to a function is available as `context.secrets[name]`. The `withStagehand` helper reads `BROWSERBASE_API_KEY` and `OPENAI_API_KEY` this way. +Keep API keys in encrypted project secrets. Each secret attached to a function is available as `context.secrets[name]`. The Stagehand examples read `BROWSERBASE_API_KEY` this way. They don't need a model API key: without a `model` option, Stagehand uses the [Browserbase Model Gateway](https://docs.stagehand.dev/v4/configuration/models#model-gateway), which picks a model for each call. Browserbase charges for the tokens. -Create the secrets and attach them to the published function with the [`browse` CLI](https://www.npmjs.com/package/browse): +Create the secret and attach it to the published function with the [`browse` CLI](https://www.npmjs.com/package/browse): ```sh browse cloud secrets create BROWSERBASE_API_KEY --env BROWSERBASE_API_KEY -browse cloud secrets create OPENAI_API_KEY --env OPENAI_API_KEY browse functions secrets attach ``` -Use the function ID from `builtFunctions[].id` in the publish output. The local development server doesn't pass secrets, so `withStagehand` falls back to the values in `.env`. +Use the Function ID from `builtFunctions[].id` in the publish output. The local development server doesn't pass secrets, so the examples fall back to the values in `.env`. ## CLI Reference @@ -227,10 +238,9 @@ Options: Set variables in your environment or in a `.env` file for the CLI and the local development server. Deployed functions read them from [secrets](#secrets) instead. -| Variable | Required | Description | -| --------------------- | ------------------------------ | ------------------------------------------- | -| `BROWSERBASE_API_KEY` | Yes | Your Browserbase API key | -| `OPENAI_API_KEY` | For the `withStagehand` helper | Your OpenAI API key for the Stagehand model | +| Variable | Required | Description | +| --------------------- | -------- | ------------------------ | +| `BROWSERBASE_API_KEY` | Yes | Your Browserbase API key | Get your API key from [browserbase.com](https://browserbase.com). diff --git a/src/cli/init/index.ts b/src/cli/init/index.ts index 511b6b2..6e0d20e 100644 --- a/src/cli/init/index.ts +++ b/src/cli/init/index.ts @@ -110,12 +110,10 @@ export async function init(options: InitOptions) { console.log(chalk.cyan("Next steps:")); console.log(chalk.gray("1. Navigate to your project:")); console.log(chalk.white(` cd ${options.projectName}`)); - console.log( - chalk.gray("2. Add your Browserbase and OpenAI API keys to .env"), - ); + console.log(chalk.gray("2. Add your Browserbase API key to .env")); console.log( chalk.gray( - "3. Upload the Stagehand extension, then paste its ID into stagehand.ts:", + "3. Upload the Stagehand extension, then paste its ID into index.ts:", ), ); console.log( @@ -137,7 +135,7 @@ export async function init(options: InitOptions) { ); console.log( chalk.gray( - "6. Create BROWSERBASE_API_KEY and OPENAI_API_KEY project secrets and attach them to the function:", + "6. Create a BROWSERBASE_API_KEY project secret and attach it to the function:", ), ); console.log( @@ -237,7 +235,7 @@ function installDependencies( console.log(chalk.gray(" Installing @browserbasehq/stagehand...")); runInstallCommand(`${installCmd} @browserbasehq/stagehand`, targetDir); - // Two zod copies make schemas passed to Stagehand fail type checks, so match Stagehand's version. + // Match Stagehand's version of zod in order to pass type checks. const zodVersion = readStagehandZodVersion(targetDir); console.log(chalk.gray(" Installing zod...")); runInstallCommand( @@ -340,15 +338,6 @@ function createStarterFunction(targetDir: string) { } else { console.log(chalk.yellow("✓ index.ts already exists")); } - - const helperPath = join(targetDir, "stagehand.ts"); - if (!existsSync(helperPath)) { - const templatePath = join(__dirname, "templates", "stagehand.ts.template"); - copyFileSync(templatePath, helperPath); - console.log(chalk.green("✓ Stagehand helper created (stagehand.ts)")); - } else { - console.log(chalk.yellow("✓ stagehand.ts already exists")); - } } function updateTsConfig(targetDir: string) { diff --git a/src/cli/init/templates/.env.template b/src/cli/init/templates/.env.template index ccd3685..9d36687 100644 --- a/src/cli/init/templates/.env.template +++ b/src/cli/init/templates/.env.template @@ -1,8 +1,4 @@ # Browserbase Configuration -# Get your API key from https://browserbase.com +# Get your API key from https://browserbase.com/settings -# Your Browserbase API key BROWSERBASE_API_KEY=your_api_key_here - -# Your model API key for Stagehand. Using OpenAI for this example -OPENAI_API_KEY=your_openai_api_key_here diff --git a/src/cli/init/templates/pnpm-workspace.yaml.template b/src/cli/init/templates/pnpm-workspace.yaml.template index 2570873..8a445d5 100644 --- a/src/cli/init/templates/pnpm-workspace.yaml.template +++ b/src/cli/init/templates/pnpm-workspace.yaml.template @@ -1,4 +1,3 @@ -# pnpm settings for this project. This file does not make the project a workspace. -# pnpm 11 and later fail installs unless esbuild's build script is allowed. +# pnpm 11 and later fail installs when esbuild's build script is not approved. allowBuilds: esbuild: true diff --git a/src/cli/init/templates/stagehand.ts.template b/src/cli/init/templates/stagehand.ts.template deleted file mode 100644 index 610ef79..0000000 --- a/src/cli/init/templates/stagehand.ts.template +++ /dev/null @@ -1,54 +0,0 @@ -import { browserbase, type Page, Stagehand } from "@browserbasehq/stagehand"; - -// Upload the Stagehand extension once, then paste its ID here: -// browse cloud extensions upload node_modules/@browserbasehq/stagehand/dist/assets/stagehand-extension.zip -// The build reads sessionConfig into the Function manifest, so use a literal value. -export const stagehandSessionConfig = { - extensionId: "your-extension-id", -}; - -interface StagehandContext { - session: { id: string }; - secrets?: Record; -} - -// Deployed Functions read attached secrets. The local dev server has none, so fall back to .env. -function readSecret(context: StagehandContext, name: string): string { - const value = context.secrets?.[name] ?? process.env[name]; - if (!value) { - throw new Error( - `Secret "${name}" is not set. Attach it to the function or add it to .env.`, - ); - } - return value; -} - -export async function withStagehand( - context: StagehandContext, - run: (tools: { stagehand: Stagehand; page: Page }) => Promise, -): Promise { - // Attach to the session that Browserbase created for this invocation. - // Don't call browser.close(): it releases the session, and Browserbase releases it when the invocation ends. - const browser = await browserbase.connect({ - apiKey: readSecret(context, "BROWSERBASE_API_KEY"), - sessionId: context.session.id, - }); - - const stagehand = await Stagehand.create({ - browser, - model: { - modelName: "openai/gpt-5.6-sol", - apiKey: readSecret(context, "OPENAI_API_KEY"), - }, - }); - - try { - const page = await browser.context.activePage(); - if (!page) { - throw new Error("Stagehand initialized without an active page"); - } - return await run({ stagehand, page }); - } finally { - await stagehand.close(); - } -} diff --git a/src/cli/init/templates/starter-function.ts.template b/src/cli/init/templates/starter-function.ts.template index af8818f..b7c25b8 100644 --- a/src/cli/init/templates/starter-function.ts.template +++ b/src/cli/init/templates/starter-function.ts.template @@ -1,43 +1,44 @@ import { defineFn } from "@browserbasehq/sdk-functions"; +import { browserbase, Stagehand } from "@browserbasehq/stagehand"; import { z } from "zod/v4"; -import { stagehandSessionConfig, withStagehand } from "./stagehand.js"; - // This is your first Browserbase function! // You can run it locally with: bb dev index.ts // Once ready, publish it with: bb publish index.ts -const HNStories = z.object({ - stories: z - .array( - z.object({ - rank: z.number(), - title: z.string(), - url: z.string(), - }), - ) - .max(3), -}); - defineFn( "my-function", - (context) => - withStagehand(context, async ({ stagehand, page }) => { - console.log("Navigating to Hacker News..."); - await page.goto("https://news.ycombinator.com"); + async (context) => { + const browser = await browserbase.connect({ + // The local dev server has no secrets, so fall back to .env. + apiKey: + context.secrets.BROWSERBASE_API_KEY ?? process.env.BROWSERBASE_API_KEY!, + sessionId: context.session.id, + }); + // In this example, Stagehand uses the Model Gateway where Browserbase charges for the tokens + const stagehand = await Stagehand.create({ browser }); + const page = (await browser.context.activePage())!; - const { data } = await stagehand.extract( - "Extract the top 3 stories with their rank, title, and link URL.", - HNStories, - ); - - console.log(`Successfully extracted ${data.stories.length} stories`); + await page.goto("https://news.ycombinator.com"); + const { data } = await stagehand.extract( + "Extract the top 3 stories with their rank, title, and link URL.", + z.object({ + stories: z + .array(z.object({ rank: z.number(), title: z.string(), url: z.string() })) + .max(3), + }), + ); - return { - message: "Successfully fetched top Hacker News stories", - timestamp: new Date().toISOString(), - results: data.stories, - }; - }), - { sessionConfig: stagehandSessionConfig }, + await stagehand.close(); + return { + message: "Successfully fetched top Hacker News stories", + timestamp: new Date().toISOString(), + results: data.stories, + }; + }, + { + // Upload the Stagehand extension once, then paste its ID here: + // browse cloud extensions upload node_modules/@browserbasehq/stagehand/dist/assets/stagehand-extension.zip + sessionConfig: { extensionId: "your-extension-id" }, + }, ); diff --git a/tests/integration/cli/init.test.ts b/tests/integration/cli/init.test.ts index 6e93227..13ebf0b 100644 --- a/tests/integration/cli/init.test.ts +++ b/tests/integration/cli/init.test.ts @@ -42,14 +42,16 @@ describe("Init Command", () => { existsSync(join(projectDir, "index.ts")), "index.ts should exist", ); - assert.ok( - existsSync(join(projectDir, "stagehand.ts")), - "stagehand.ts should exist", - ); + const starter = readFileSync(join(projectDir, "index.ts"), "utf-8"); assert.doesNotMatch( - readFileSync(join(projectDir, "stagehand.ts"), "utf-8"), + starter, /await browser\.close\(\)/, - "stagehand.ts should not release the Function's session with browser.close()", + "index.ts should not release the Function's session with browser.close()", + ); + assert.match( + starter, + /Stagehand\.create\(\{ browser \}\)/, + "index.ts should omit the model so the Model Gateway picks one", ); assert.ok(existsSync(join(projectDir, ".env")), ".env should exist"); assert.ok(