Skip to main content
Stagehand provides a unified API for managing browser contexts, pages, and iframes. Understanding this architecture helps you build more reliable multi-page automations.

Context Architecture

The V3Context class is the heart of Stagehand’s browser management:

CDP Connection

Single WebSocket connection to Chrome DevTools Protocol

Page Registry

Tracks all top-level pages (tabs) in the browser

Frame Management

Handles iframes and OOPIF (out-of-process iframes)

Target Lifecycle

Auto-attaches to new pages and cleans up closed ones

Accessing the Context

The context is available through your Stagehand instance:
const stagehand = new Stagehand({ env: "LOCAL" });
await stagehand.init();

// Access the context
const context = stagehand.context;
All browser-level operations go through the context:
example.ts:7-21
const page = stagehand.context.pages()[0];
await page.goto(
  "https://browserbase.github.io/stagehand-eval-sites/sites/iframe-hn/",
);

const page2 = await stagehand.context.newPage();
await page2.goto(
  "https://browserbase.github.io/stagehand-eval-sites/sites/iframe-same-proc/",
);

Working with Pages

Pages represent individual browser tabs:

Getting Pages

The most recently interacted-with page:
const page = stagehand.context.activePage();
This is determined by:
  1. The most recent page you explicitly set active
  2. The last page a new window opened from
  3. The newest page by creation time
Source: context.ts:282-302

Creating Pages

Create a new browser tab:
const newPage = await stagehand.context.newPage("https://example.com");
The new page is automatically registered and becomes the active page.
How it works:
context.ts:438-467
public async newPage(url = "about:blank"): Promise<Page> {
  const targetUrl = String(url ?? "about:blank");
  const { targetId } = await this.conn.send<{ targetId: string }>(
    "Target.createTarget",
    // Create at about:blank so init scripts can install before first real navigation.
    { url: "about:blank" },
  );
  this.pendingCreatedTargetUrl.set(targetId, "about:blank");
  // Best-effort bring-to-front
  await this.conn.send("Target.activateTarget", { targetId }).catch(() => {});

  const deadline = Date.now() + 5000;
  while (Date.now() < deadline) {
    const page = this.pagesByTarget.get(targetId);
    if (page) {
      // we created at about:blank; navigate only after attach so init scripts run
      // on the first real document. Fire-and-forget so newPage() resolves on attach.
      if (targetUrl !== "about:blank") {
        // Seed requested URL into the page cache before navigation events arrive.
        page.seedCurrentUrl(targetUrl);
        void page
          .sendCDP("Page.navigate", { url: targetUrl })
          .catch(() => {});
      }
      return page;
    }
    await new Promise((r) => setTimeout(r, 25));
  }
  throw new TimeoutError(`newPage: target not attached (${targetId})`, 5000);
}
Pages start at about:blank to allow init scripts to install before the first real navigation.

Setting Active Page

Explicitly mark a page as active:
stagehand.context.setActivePage(page);
This:
  1. Updates internal recency tracking
  2. Brings the tab to the foreground (in headful mode)
  3. Makes it the default page for subsequent actions
Source: context.ts:305-327

Passing Pages to Methods

By default, Stagehand methods operate on the active page. To target a specific page:
const page1 = stagehand.context.pages()[0];
const page2 = await stagehand.context.newPage();

// Act on page2
await stagehand.act("click the submit button", { page: page2 });

// Extract from page1
const data = await stagehand.extract(
  "get the product title",
  { page: page1 }
);

// Observe on a specific page
const actions = await stagehand.observe(
  "find all links",
  { page: page1 }
);
Explicit page passing is essential for multi-tab workflows.
Example from the codebase:
example.ts:17-34
const page2 = await stagehand.context.newPage();
await page2.goto(
  "https://browserbase.github.io/stagehand-eval-sites/sites/iframe-same-proc/",
);
await stagehand.extract(
  "extract the placeholder text on the your name field",
  { page: page2 },
);
await stagehand.act("fill the your name field with the text 'John Doe'", {
  page: page2,
});
const action2 = await stagehand.observe(
  "select blue as the favorite color on the dropdown",
  { page: page2 },
);
for (const action of action2) {
  await stagehand.act(action, { page: page2, timeout: 30_000 });
}

Frame Management

Stagehand automatically handles iframes and OOPIFs:

Shadow DOM Piercing

Stagehand can interact with elements inside shadow roots:
v3.ts:633-639
installPromises.push(
  session
    .send("Page.addScriptToEvaluateOnNewDocument", {
      source: v3ScriptContent, // Piercer script
      runImmediately: true,
    })
);
The “piercer” script modifies Element.prototype.attachShadow to keep shadow roots open, allowing Stagehand to query inside them.

Cross-Process Iframes (OOPIF)

Modern browsers run some iframes in separate processes. Stagehand handles this automatically:
context.ts:702-738
// Child (iframe / OOPIF)
try {
  const { frameTree } =
    await session.send<Protocol.Page.GetFrameTreeResponse>(
      "Page.getFrameTree",
    );
  const childMainId = frameTree.frame.id;

  // Try to find owner Page now
  let owner = this.frameOwnerPage.get(childMainId);
  if (!owner) {
    // Search existing pages for this frame
    for (const p of this.pagesByTarget.values()) {
      const tree = p.asProtocolFrameTree(p.mainFrameId());
      const has = (function find(n: Protocol.Page.FrameTree): boolean {
        if (n.frame.id === childMainId) return true;
        for (const c of n.childFrames ?? []) if (find(c)) return true;
        return false;
      })(tree);
      if (has) {
        owner = p;
        break;
      }
    }
  }

  if (owner) {
    owner.adoptOopifSession(session, childMainId);
    this.sessionOwnerPage.set(sessionId, owner);
    this.installFrameEventBridges(sessionId, owner);
  }
}
You don’t need to think about OOPIFs. Stagehand treats them as part of the parent page’s frame tree.

Page Lifecycle Events

V3Context listens to CDP target lifecycle events:
1

Target.attachedToTarget

A new page or iframe has been created. Stagehand:
  • Creates a CDP session
  • Enables required domains (Page, Runtime, Network)
  • Installs init scripts and the piercer
  • Registers the page/frame
Source: context.ts:556-746
2

Target.detachedFromTarget

A page or iframe was closed. Stagehand:
  • Removes the session from ownership maps
  • Cleans up the frame subtree
  • Updates the active page
Source: context.ts:754-777
3

Target.targetDestroyed

Fallback cleanup when a target is destroyed without detach event.Source: context.ts:780-804

Frame Event Bridges

Stagehand forwards CDP frame events to the owning Page:
context.ts:810-877
private installFrameEventBridges(sessionId: SessionId, owner: Page): void {
  const session = this.conn.getSession(sessionId);
  if (!session) return;

  session.on<Protocol.Page.FrameAttachedEvent>(
    "Page.frameAttached",
    (evt) => {
      const { frameId, parentFrameId } = evt;
      owner.onFrameAttached(frameId, parentFrameId ?? null, session);

      // If we were waiting for this id (OOPIF child), adopt now.
      const pendingChildSessionId = this.pendingOopifByMainFrame.get(frameId);
      if (pendingChildSessionId) {
        const child = this.conn.getSession(pendingChildSessionId);
        if (child) {
          owner.adoptOopifSession(child, frameId);
          this.sessionOwnerPage.set(child.id, owner);
          this.installFrameEventBridges(pendingChildSessionId, owner);
        }
        this.pendingOopifByMainFrame.delete(frameId);
      }

      this.frameOwnerPage.set(frameId, owner);
    },
  );

  session.on<Protocol.Page.FrameDetachedEvent>("Page.frameDetached", (evt) => {
    owner.onFrameDetached(evt.frameId, evt.reason ?? "remove");
    if (evt.reason !== "swap") {
      this.frameOwnerPage.delete(evt.frameId);
    }
  });

  session.on<Protocol.Page.FrameNavigatedEvent>("Page.frameNavigated", (evt) => {
    owner.onFrameNavigated(evt.frame, session);
  });
}
This architecture keeps frame ownership logic centralized in Page, while Context just routes events.

Init Scripts

Init scripts run on every new document (including iframes) before any page code executes:
await stagehand.context.addInitScript(() => {
  // This runs in the page context
  window.__stagehand = { debug: true };
});

// Or with arguments
await stagehand.context.addInitScript(
  (config) => {
    window.__config = config;
  },
  { apiKey: "secret" }
);
Use cases:
  • Mock APIs or data
  • Override browser APIs
  • Inject debug flags
  • Set up test fixtures
Source: context.ts:329-338

Extra HTTP Headers

Set headers for all requests:
await stagehand.context.setExtraHTTPHeaders({
  "Authorization": "Bearer token123",
  "X-Custom-Header": "value",
});
This applies to all pages in the context. There’s no per-page header support.
Source: context.ts:340-383 Manage cookies at the context level:

Get Cookies

// All cookies
const allCookies = await stagehand.context.cookies();

// Cookies for specific URL(s)
const siteCookies = await stagehand.context.cookies("https://example.com");
const multiSiteCookies = await stagehand.context.cookies([
  "https://example.com",
  "https://another.com"
]);
Source: context.ts:945-964

Add Cookies

await stagehand.context.addCookies([
  {
    name: "session",
    value: "abc123",
    domain: "example.com",
    path: "/",
    httpOnly: true,
    secure: true,
  },
]);
Source: context.ts:974-991

Clear Cookies

// Clear all cookies
await stagehand.context.clearCookies();

// Clear specific cookies by name
await stagehand.context.clearCookies({ name: "session" });

// Clear by domain
await stagehand.context.clearCookies({ domain: "example.com" });

// Clear by path
await stagehand.context.clearCookies({ path: "/admin" });
Source: context.ts:1003-1038
Cookie operations are atomic on the browser endpoint, avoiding race conditions.

Multi-Page Patterns

Parallel Operations

Operate on multiple pages concurrently:
const pages = await Promise.all([
  stagehand.context.newPage("https://example.com/page1"),
  stagehand.context.newPage("https://example.com/page2"),
  stagehand.context.newPage("https://example.com/page3"),
]);

const results = await Promise.all(
  pages.map(page => 
    stagehand.extract("get the page title", { page })
  )
);

Sequential Page Switching

Switch between pages in a workflow:
const searchPage = stagehand.context.pages()[0];
const resultPage = await stagehand.context.newPage();

// Search on first page
await stagehand.act("type 'shoes' into the search box", { page: searchPage });
await stagehand.act("click 'Search'", { page: searchPage });

// Navigate second page
await resultPage.goto("https://example.com/results");

// Extract from second page
const results = await stagehand.extract(
  "get all product names",
  { page: resultPage }
);

Handling Popups

When a page opens a popup:
// Trigger the popup
await stagehand.act("click 'Open Details'");

// Wait for the new page
const newPage = await stagehand.context.awaitActivePage();

// The popup is now active
await stagehand.extract("get the details", { page: newPage });
How it works:
context.ts:904-936
async awaitActivePage(timeoutMs?: number): Promise<Page> {
  const defaultTimeout = this.env === "BROWSERBASE" ? 4000 : 2000;
  timeoutMs = timeoutMs ?? defaultTimeout;
  
  // If a popup was just triggered, wait for the new page
  const recentWindowMs = this.env === "BROWSERBASE" ? 1000 : 300;
  const now = Date.now();
  const hasRecentPopup = now - this._lastPopupSignalAt <= recentWindowMs;

  const immediate = this.activePage();
  if (!hasRecentPopup && immediate) return immediate;

  const deadline = now + timeoutMs;
  while (Date.now() < deadline) {
    // Prefer most-recent by createdAt
    let newestTid: TargetId | undefined;
    let newestTs = -1;
    for (const [tid] of this.pagesByTarget) {
      const ts = this.createdAtByTarget.get(tid) ?? 0;
      if (ts > newestTs) {
        newestTs = ts;
        newestTid = tid;
      }
    }
    if (newestTid) {
      const p = this.pagesByTarget.get(newestTid);
      if (p && newestTs >= this._lastPopupSignalAt) return p;
    }
    await new Promise((r) => setTimeout(r, 25));
  }
  throw new PageNotFoundError("awaitActivePage: no page available");
}

Cleanup & Lifecycle

When you close Stagehand:
await stagehand.close();
This:
  1. Closes the CDP connection
  2. Clears all page and frame registries
  3. Kills the local Chrome process (if env: "LOCAL")
  4. Ends the Browserbase session (if keepAlive: false)
Source: context.ts:472-482
Always call close() to prevent resource leaks, especially in long-running services.

Best Practices

Don’t rely on “active page” logic in multi-page flows:
// Good: Explicit
const page1 = stagehand.context.pages()[0];
const page2 = await stagehand.context.newPage();
await stagehand.act("click", { page: page1 });
await stagehand.act("type", { page: page2 });

// Risky: Implicit active page
await stagehand.act("click");
await stagehand.act("type"); // Which page?
Store page references when you create them:
const pages = {
  search: stagehand.context.pages()[0],
  results: await stagehand.context.newPage(),
  details: await stagehand.context.newPage(),
};

await stagehand.act("search", { page: pages.search });
await stagehand.extract("results", { page: pages.results });
Use awaitActivePage() when pages open from user actions:
await stagehand.act("click the link that opens a new tab");
const newPage = await stagehand.context.awaitActivePage();
// Now interact with the new page
Add init scripts before creating pages:
await stagehand.context.addInitScript(() => {
  window.__testMode = true;
});
// Now all new pages will have the script
const page = await stagehand.context.newPage();

Next Steps

Leverage Caching

Speed up repeated actions

Write Better Instructions

Guide the AI effectively

Page API Reference

Full Page class documentation

Multi-Page Examples

See real multi-page workflows

Build docs developers (and LLMs) love