Skip to content

Update stagehand template to v4 - #221

Open
dprevoznik wants to merge 4 commits into
mainfrom
hypeship/stagehand-v4
Open

Update stagehand template to v4#221
dprevoznik wants to merge 4 commits into
mainfrom
hypeship/stagehand-v4

Conversation

@dprevoznik

@dprevoznik dprevoznik commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Migrates pkg/templates/typescript/stagehand from 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 no extensionId it falls back to Extensions.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; add ws and @types/ws. (zod ^4.2.0 already satisfies Stagehand v4's zod@4.4.3.)
  • index.ts:
    • Upload the Stagehand extension (shipped at dist/assets/stagehand-extension.zip inside the package) to the project once via kernel.extensions.upload, reused on later runs.
    • Create the browser with extensions: [{ name }].
    • Discover the extension's Chrome runtime id from its service worker target over CDP (Target.getTargets).
    • Connect with localBrowser.connect({ cdpUrl, extensionId }) and Stagehand.create({ browser }).
    • await browser.context.activePage() (page access is async in v4).
    • Unwrap stagehand.extract(...) via { data } (v4 primitives return { data, metadata }).
    • Close the connected browser explicitly (v4 only closes browsers it launched).
    • Read MODEL / MODEL_API_KEY from env, defaulting to openai/gpt-4.1.
  • .env.example, README.md: document MODEL / MODEL_API_KEY and the extension-based connection flow.
  • pnpm-lock.yaml: regenerated.

Testing

  • tsc --noEmit passes with the template's resolved deps (stagehand 4.0.0, @onkernel/sdk 0.23.0, zod 4.4.3, ws 8.21.3).
  • Deployed the template with kernel deploy and ran kernel invoke ts-stagehand teamsize-task --payload '{"company":"kernel"}'; it returned {"teamSize":"6"}. Deploy was run with MODEL=google/gemini-2.5-flash since 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.zip once via kernel.extensions, creates the browser with that extension preloaded, derives Chrome’s runtime id from Kernel’s load path (chromeExtensionId), then connects with localBrowser.connect({ cdpUrl, extensionId }) + Stagehand.create.

Also updates the v4 API surface (activePage(), unwrap { data } from extract), switches env config to MODEL / 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.

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>
@socket-security

socket-security Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​browserbasehq/​stagehand@​4.0.081100100100100

View full report

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ 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.

Comment thread pkg/templates/typescript/stagehand/index.ts Outdated
Comment thread pkg/templates/typescript/stagehand/index.ts Outdated
- 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 rgarcia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@dprevoznik

Copy link
Copy Markdown
Contributor Author

Done in 95e86b2 — adopted the deterministic derivation.

  • Added chromeExtensionId() (SHA-256 of /home/kernel/extensions/<id>, first 16 bytes, nibbles mapped a..p) and removed discoverExtensionId() and the ws / @types/ws deps.
  • ensureStagehandExtension now returns the Kernel extension id; the browser is created with extensions: [{ id }] and connects with the computed id.

Verified against a real Kernel browser: the computed id matched the Stagehand extension's runtime service worker exactly, and a full deploy + invoke returned {"teamSize":"6"}. Also confirmed a stealth: true browser exposes three extension service workers (Stagehand + the stealth extensions), so dropping the service-worker.js filename match is a real robustness win, not just less code.

I documented both assumptions the derivation rests on in a comment: the /home/kernel/extensions/<id> extraction path, and the keyless manifest. I left out a runtime manifest-key assertion to keep the template lean (it'd mean unzipping the archive just to guard) — happy to add the unit test you suggested (fixed Kernel id → expected Chrome id, plus a keyless-manifest assertion) if you'd like it in this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants