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
6 changes: 4 additions & 2 deletions pkg/templates/typescript/stagehand/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
# Copy this file to .env and fill in your API key
OPENAI_API_KEY=your_openai_api_key_here
# Copy this file to .env and fill in your API key.
# Defaults to OpenAI. To use another provider, also set MODEL (with a provider prefix),
# e.g. MODEL=google/gemini-2.5-flash
MODEL_API_KEY=your_openai_api_key_here
24 changes: 20 additions & 4 deletions pkg/templates/typescript/stagehand/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
# Kernel TypeScript Sample App - Stagehand
# Kernel TypeScript Sample App - Stagehand (v4)

A Stagehand-powered browser automation app that extracts team size information from Y Combinator company pages.
A [Stagehand v4](https://docs.stagehand.dev) browser automation app that extracts team size information from Y Combinator company pages, running against a Kernel cloud browser.

## What it does

The `teamsize-task` searches for a startup on Y Combinator's company directory and extracts the team size (number of employees).

## How Stagehand v4 connects to a Kernel browser

Stagehand v4 no longer drives the browser purely over CDP — it runs as a **Chrome extension** next to the browser. To use it with a remote Kernel browser, this template:

1. Uploads the Stagehand extension (shipped inside the `@browserbasehq/stagehand` package) to your Kernel project once.
2. Creates the Kernel browser with that extension preloaded (`extensions: [{ name }]`).
3. Reads the extension's runtime id off its service worker over CDP.
4. Connects with `localBrowser.connect({ cdpUrl, extensionId })` and `Stagehand.create({ browser })`.

## Input

```json
Expand All @@ -24,10 +33,17 @@ The `teamsize-task` searches for a startup on Y Combinator's company directory a

## Setup

Create a `.env` file:
Create a `.env` file (defaults to OpenAI):

```
MODEL_API_KEY=your-openai-api-key
```

To use a different provider, also set `MODEL` (with a provider prefix):

```
OPENAI_API_KEY=your-openai-api-key
MODEL=google/gemini-2.5-flash
MODEL_API_KEY=your-gemini-api-key
```

## Deploy
Expand Down
171 changes: 128 additions & 43 deletions pkg/templates/typescript/stagehand/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { Stagehand } from "@browserbasehq/stagehand";
import { Kernel, type KernelContext } from '@onkernel/sdk';
import { z } from 'zod';
import { Stagehand, localBrowser, type ModelName } from "@browserbasehq/stagehand";
import { Kernel, type KernelContext } from "@onkernel/sdk";
import { createReadStream } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { WebSocket } from "ws";
// Stagehand v4 uses zod v4 schema types.
import { z } from "zod";

const kernel = new Kernel();

const app = kernel.app('ts-stagehand');
const app = kernel.app("ts-stagehand");

interface CompanyInput {
company: string;
Expand All @@ -14,17 +19,89 @@ interface TeamSizeOutput {
teamSize: string;
}

// LLM API Keys are set in the environment during `kernel deploy <filename> -e OPENAI_API_KEY=XXX`
// LLM API Keys are set in the environment during `kernel deploy <filename> -e MODEL_API_KEY=XXX`
// See https://www.kernel.sh/docs/apps/deploy#environment-variables
// Defaults to OpenAI. Override MODEL to use another provider (e.g. google/gemini-2.5-flash).
const MODEL = (process.env.MODEL ?? "openai/gpt-4.1") as ModelName;
const MODEL_API_KEY = process.env.MODEL_API_KEY ?? process.env.OPENAI_API_KEY;

const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
if (!MODEL_API_KEY) {
throw new Error("MODEL_API_KEY (or OPENAI_API_KEY) is not set");
}

if (!OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY is not set');
// Stagehand v4 runs as a Chrome extension alongside the browser. When connecting
// to a remote Kernel browser over CDP we preload that extension at browser
// creation and hand its runtime id to `localBrowser.connect`.
const STAGEHAND_EXTENSION_NAME = "stagehand-runtime";

// The extension archive ships inside the installed @browserbasehq/stagehand package.
const stagehandDist = dirname(fileURLToPath(import.meta.resolve("@browserbasehq/stagehand")));
const STAGEHAND_EXTENSION_ZIP = join(stagehandDist, "assets/stagehand-extension.zip");

// Upload the Stagehand extension to the project once and reuse it thereafter.
async function ensureStagehandExtension(): Promise<string> {
const existing = await kernel.extensions.list();
for (const ext of existing) {
if (ext.name === STAGEHAND_EXTENSION_NAME) return STAGEHAND_EXTENSION_NAME;
}
await kernel.extensions.upload({
file: createReadStream(STAGEHAND_EXTENSION_ZIP),
name: STAGEHAND_EXTENSION_NAME,
});
return STAGEHAND_EXTENSION_NAME;
}

// Chrome assigns the preloaded extension a runtime id. Read it off the extension's
// service worker target so we can attach Stagehand to it.
async function discoverExtensionId(cdpUrl: string, timeoutMs = 20_000): Promise<string> {
return await new Promise<string>((resolve, reject) => {
const ws = new WebSocket(cdpUrl);
let settled = false;
let messageId = 0;

const finish = (error: Error | null, id?: string) => {
if (settled) return;
settled = true;
clearTimeout(timer);
clearInterval(poller);
ws.close();
error ? reject(error) : resolve(id!);
};

const poll = () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ id: ++messageId, method: "Target.getTargets" }));
}
};
const timer = setTimeout(
() => finish(new Error("Timed out waiting for the Stagehand extension service worker")),
timeoutMs,
);
const poller = setInterval(poll, 500);

ws.on("open", poll);
ws.on("error", (error: Error) => finish(error));
ws.on("close", () => finish(new Error("CDP socket closed before the Stagehand extension was found")));
ws.on("message", (buf: Buffer) => {
let targets: Array<{ type: string; url: string }> | undefined;
try {
targets = JSON.parse(buf.toString()).result?.targetInfos;
} catch {
return;
}
const worker = targets?.find(
(t) =>
t.type === "service_worker" &&
t.url.startsWith("chrome-extension://") &&
t.url.includes("service-worker.js"),
);
if (worker) finish(null, worker.url.split("/")[2]);
});
});
Comment thread
cursor[bot] marked this conversation as resolved.
}

app.action<CompanyInput, TeamSizeOutput>(
'teamsize-task',
"teamsize-task",
async (ctx: KernelContext, payload?: CompanyInput): Promise<TeamSizeOutput> => {
// A function that returns the team size of a Y Combinator startup

Expand All @@ -35,48 +112,56 @@ app.action<CompanyInput, TeamSizeOutput>(
// Returns:
// output: The team size (number of employees) of the startup

const company = payload?.company || 'kernel';
const company = payload?.company || "kernel";

const extensionName = await ensureStagehandExtension();

const kernelBrowser = await kernel.browsers.create({
invocation_id: ctx.invocation_id,
stealth: true,
extensions: [{ name: extensionName }],
});

console.log("Kernel browser live view url: ", kernelBrowser.browser_live_view_url);

const stagehand = new Stagehand({
env: "LOCAL",
localBrowserLaunchOptions: {
cdpUrl: kernelBrowser.cdp_ws_url,
},
model: "openai/gpt-4.1",
apiKey: OPENAI_API_KEY,
verbose: 1,
domSettleTimeout: 30_000
});
await stagehand.init();

/////////////////////////////////////
// Your Stagehand implementation here
/////////////////////////////////////
const page = stagehand.context.pages()[0];
await page.goto("https://www.ycombinator.com/companies");
// Stagehand only closes browsers it launched, so we close the connection and
// delete the Kernel browser ourselves, even if the automation throws.
let stagehand: Awaited<ReturnType<typeof Stagehand.create>> | undefined;
let browser: Awaited<ReturnType<typeof localBrowser.connect>> | undefined;
try {
const extensionId = await discoverExtensionId(kernelBrowser.cdp_ws_url);

await stagehand.act(`Type in ${company} into the search box`);
await stagehand.act("Click on the first search result");

// Schema definition
const teamSizeSchema = z.object({
teamSize: z.string(),
});
// Extract team size from the YC startup page
const output = await stagehand.extract(
"Extract the team size (number of employees) shown on this Y Combinator company page.",
teamSizeSchema
);
await stagehand.close();
await kernel.browsers.deleteByID(kernelBrowser.session_id);

return output;
browser = await localBrowser.connect({
cdpUrl: kernelBrowser.cdp_ws_url,
extensionId,
});
stagehand = await Stagehand.create({
browser,
model: { modelName: MODEL, apiKey: MODEL_API_KEY },
logging: { level: "info" },
});

/////////////////////////////////////
// Your Stagehand implementation here
/////////////////////////////////////
const page = await browser.context.activePage();
if (!page) throw new Error("No active page in the Kernel browser");
await page.goto("https://www.ycombinator.com/companies");

await stagehand.act(`Type in ${company} into the search box`);
await stagehand.act("Click on the first search result");

// Extract team size from the YC startup page. Every v4 primitive returns { data }.
const { data } = await stagehand.extract(
"Extract the team size (number of employees) shown on this Y Combinator company page.",
z.object({ teamSize: z.string() }),
);

return data;
} finally {
await stagehand?.close();
await browser?.close();
await kernel.browsers.deleteByID(kernelBrowser.session_id);
}
},
);
4 changes: 3 additions & 1 deletion pkg/templates/typescript/stagehand/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
"type": "module",
"private": true,
"dependencies": {
"@browserbasehq/stagehand": "^3.5.0",
"@browserbasehq/stagehand": "^4.0.0",
"@onkernel/sdk": "^0.23.0",
"ws": "^8.18.0",
"zod": "^4.2.0"
},
"devDependencies": {
"@types/node": "^22.15.17",
"@types/ws": "^8.5.12",
"typescript": "^5.9.3"
}
}
Loading
Loading