Update stagehand template to v4 - #221
Conversation
Stagehand v4 runs as a Chrome extension next to the browser rather than
driving it purely over CDP, so the template now preloads that extension
into the Kernel browser and connects to it.
Changes to pkg/templates/typescript/stagehand:
- Bump @browserbasehq/stagehand to ^4.0.0; add ws (+ @types/ws).
- Replace `new Stagehand({ env: "LOCAL", localBrowserLaunchOptions })` +
`init()` with `localBrowser.connect({ cdpUrl, extensionId })` +
`Stagehand.create({ browser })`.
- Upload the Stagehand extension (shipped in the npm package) to the
project, create the browser with `extensions: [{ name }]`, and discover
the extension's runtime id from its service worker over CDP.
- Use `await browser.context.activePage()` (page access is async in v4)
and unwrap `stagehand.extract(...)` via `{ data }`.
- Close the connected browser explicitly (v4 only closes browsers it
launched).
- Read MODEL / MODEL_API_KEY from env, defaulting to openai/gpt-4.1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ca622fd. Configure here.
- discoverExtensionId: settle on an overall timeout, socket close, and errors, and guard JSON parsing, so a run can no longer hang past the timeout without reaching cleanup. - Move browser teardown (stagehand.close, browser.close, deleteByID) into a finally block so a failed run no longer leaks the Kernel browser, matching the sibling templates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
rgarcia
left a comment
There was a problem hiding this comment.
Could we simplify the extension ID discovery by deriving the Chrome runtime ID from the Kernel extension ID returned by the upload/list call?
Kernel extracts stored extensions at /home/kernel/extensions/<kernel-extension-id>. For an unpacked extension without a manifest key (which is true for the bundled Stagehand runtime), Chrome deterministically derives the runtime ID from that absolute path: SHA-256 the path, take the first 16 bytes, and map each nibble from 0..f to a..p.
That would let us remove the raw CDP websocket polling, timeout/error-handling code, and the ws / @types/ws dependencies. It also avoids identifying the extension by the relatively generic service-worker.js filename.
For example:
import { createHash } from "node:crypto";
function chromeExtensionId(kernelExtensionId: string): string {
const extensionPath = `/home/kernel/extensions/${kernelExtensionId}`;
const digest = createHash("sha256").update(extensionPath).digest().subarray(0, 16);
return [...digest]
.flatMap((byte) => [byte >> 4, byte & 0xf])
.map((nibble) => String.fromCharCode("a".charCodeAt(0) + nibble))
.join("");
}ensureStagehandExtension could return the extension record instead of only its friendly name:
async function ensureStagehandExtension() {
const existing = await kernel.extensions.list();
const extension = existing.find(
(ext) => ext.name === STAGEHAND_EXTENSION_NAME,
);
if (extension) return extension;
return await kernel.extensions.upload({
file: createReadStream(STAGEHAND_EXTENSION_ZIP),
name: STAGEHAND_EXTENSION_NAME,
});
}Then browser creation and Stagehand connection become:
const extension = await ensureStagehandExtension();
const extensionId = chromeExtensionId(extension.id);
const kernelBrowser = await kernel.browsers.create({
invocation_id: ctx.invocation_id,
stealth: true,
extensions: [{ id: extension.id }],
});
const browser = await localBrowser.connect({
cdpUrl: kernelBrowser.cdp_ws_url,
extensionId,
});I tested this against a real Kernel browser with the uploaded Stagehand v4 runtime: the computed ID exactly matched the extension service worker's runtime ID, and localBrowser.connect plus a Stagehand extraction succeeded. Connecting without the ID failed in Extensions.loadUnpacked as expected because that path is resolved on the browser filesystem.
The constraint is that this derivation applies to unpacked extensions without a manifest key. Since the Stagehand archive currently has no key, I think that is a reasonable, testable assumption for this template. We could add a small unit test with a fixed Kernel extension ID / expected Chrome ID pair, and optionally assert that the bundled manifest remains keyless.
Chrome derives an unpacked extension's runtime id from the absolute path
it loads from. Kernel extracts a preloaded extension to
/home/kernel/extensions/<kernel-extension-id>, so the runtime id can be
computed directly (SHA-256 the path, first 16 bytes, each nibble mapped
to a..p) rather than polling the browser's targets over CDP.
- Add chromeExtensionId() and drop discoverExtensionId().
- ensureStagehandExtension returns the Kernel extension id; create the
browser with extensions: [{ id }] and connect with the computed id.
- Remove the ws and @types/ws dependencies.
Holds because the Stagehand extension ships without a manifest key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Done in 95e86b2 — adopted the deterministic derivation.
Verified against a real Kernel browser: the computed id matched the Stagehand extension's runtime service worker exactly, and a full deploy + invoke returned I documented both assumptions the derivation rests on in a comment: the |

Migrates
pkg/templates/typescript/stagehandfrom Stagehand v3 to v4.Why
Stagehand v4 runs as a Chrome extension next to the browser instead of driving it purely over CDP. The v3 init path (
new Stagehand({ env: "LOCAL", localBrowserLaunchOptions: { cdpUrl } })+init()) no longer exists. For a remote Kernel browser,localBrowser.connect({ cdpUrl })alone does not work: with noextensionIdit falls back toExtensions.loadUnpacked, whose path is resolved on the browser's filesystem, not the app's. The extension must be preloaded into the Kernel browser and referenced by its runtime id.What changed
package.json:@browserbasehq/stagehand^3.5.0→^4.0.0; addwsand@types/ws. (zod^4.2.0already satisfies Stagehand v4'szod@4.4.3.)index.ts:dist/assets/stagehand-extension.zipinside the package) to the project once viakernel.extensions.upload, reused on later runs.extensions: [{ name }].Target.getTargets).localBrowser.connect({ cdpUrl, extensionId })andStagehand.create({ browser }).await browser.context.activePage()(page access is async in v4).stagehand.extract(...)via{ data }(v4 primitives return{ data, metadata }).MODEL/MODEL_API_KEYfrom env, defaulting toopenai/gpt-4.1..env.example,README.md: documentMODEL/MODEL_API_KEYand the extension-based connection flow.pnpm-lock.yaml: regenerated.Testing
tsc --noEmitpasses with the template's resolved deps (stagehand 4.0.0, @onkernel/sdk 0.23.0, zod 4.4.3, ws 8.21.3).kernel deployand rankernel invoke ts-stagehand teamsize-task --payload '{"company":"kernel"}'; it returned{"teamSize":"6"}. Deploy was run withMODEL=google/gemini-2.5-flashsince that was the model key available in the test environment; the default OpenAI path is unchanged in code.🤖 Generated with Claude Code
Note
Medium Risk
Sample-template-only change, but the new extension upload and Chrome runtime-id derivation are non-trivial and easy for users to copy incorrectly if Kernel’s load path or Stagehand’s unkeyed manifest assumptions change.
Overview
Migrates the TypeScript Stagehand sample from v3 → v4, adapting to Stagehand’s new Chrome-extension runtime instead of pure CDP control.
Connection flow now uploads the bundled
stagehand-extension.ziponce viakernel.extensions, creates the browser with that extension preloaded, derives Chrome’s runtime id from Kernel’s load path (chromeExtensionId), then connects withlocalBrowser.connect({ cdpUrl, extensionId })+Stagehand.create.Also updates the v4 API surface (
activePage(), unwrap{ data }fromextract), switches env config toMODEL/MODEL_API_KEY(with OpenAI fallback), and documents the new setup in the README.Reviewed by Cursor Bugbot for commit 1c88e9a. Bugbot is set up for automated code reviews on this repo. Configure here.