--- title: 'Browser tools' description: 'Give a deep agent a Chromium browser to navigate, act on, extract from, and screenshot the web, with per-session pooling and a domain allowlist.' position: 4 --- # Browser tools > `browserTools()` and `browserSubagent()` from `@stackbone/sdk/deep` give a > deep agent a Chromium browser. The agent navigates to a page, reads what is on > it, acts on it in plain language, pulls typed data out of it, and screenshots > it. You declare the capability in one line and the SDK builds the tools > for you. Each call runs against a Chromium page the runtime keeps alive > for the session, so a login survives from one turn to the next. `connectorTool` reaches a provider's API (see [Stackbone Connect](/docs/home/features/integrations)). The browser tools reach a page a human would open in a browser. ## What you get `browserTools()` returns six tools with fixed, model-facing names. Four of them use a model to interpret the page; two are plain navigation and capture. | Tool | What it does | Uses a model? | | -------------------- | -------------------------------------------------------------- | ------------- | | `browser_goto` | Navigate to an absolute URL and load the page. | No | | `browser_observe` | List the actions on the current page (buttons, links, inputs). | Yes | | `browser_act` | Do a natural-language action, like "click the login button". | Yes | | `browser_extract` | Pull structured, typed data off the page instead of raw HTML. | Yes | | `browser_screenshot` | Capture the page as a base64 PNG. | No | | `browser_login` | Sign in to a site once and keep the session for later runs. | Yes | Every tool returns a small JSON string the model can read. Success looks like `{ "ok": true, ... }` and failure looks like `{ "ok": false, "error": "..." }`. A tool never throws in a way that crashes the run, so the model always gets a result it can reason about. ### Stay signed in across runs A fresh browser starts logged out of every site. `browser_login(url)` opens the sign-in page, submits the operator's stored credentials, and saves the resulting session (cookies and local storage) **encrypted**, keyed by site. Later browsers start with that session already injected, so the next conversation begins authenticated instead of logging in again. The credentials come from secrets named after the site host, so you never type a password into chat. For `https://app.example.com` the agent reads `BROWSER_LOGIN_APP_EXAMPLE_COM_USERNAME` and `BROWSER_LOGIN_APP_EXAMPLE_COM_PASSWORD`. Set them in Studio under **Secrets**. If either is missing, `browser_login` returns an error naming the secrets to add. You audit and revoke saved sessions in Studio under **Browser settings**, on the **Profiles** tab. A saved session is as good as a password, so treat a revoke like a password rotation. If a site expires the session server-side, the agent notices, marks it stale, and signs in once more on the next `browser_login`. Two conversations that hit the same site at once do not both log in. One performs the login while the other waits and inherits the saved session. If the site demands a step the agent cannot complete, such as a one-time code or a captcha, `browser_login` says so instead of looping. ## Install the browser peers The browser tools drive [Stagehand](https://www.stagehand.dev/) over a real Chromium page. The SDK never imports Stagehand or Playwright on its own. They are optional dependencies you install only in a project that browses: ```sh pnpm add @browserbasehq/stagehand@3.6.0 playwright@1.59.1 pnpm exec playwright install chromium ``` If you declare a browser tool but the packages are missing, the agent fails at startup with one error naming what to install, instead of a "module not found" deep inside the first tool call. The model-backed tools (`browser_act`, `browser_extract`, `browser_observe`, `browser_login`) reason about the page through the deployment's model provider. The runtime injects `MODEL_PROVIDER_API_KEY` for you. `stackbone dev` seeds whatever you exported in your shell or configured in the Studio **Model provider** screen, so your agent code never wires a key. ## Attach the browser two ways The two forms differ in how much of the browsing loop your main agent carries. ### Delegate to the browser subagent (recommended) `browserSubagent()` attaches a ready-made `browser` specialist in one line. Your main agent hands the whole navigation loop to it and gets back only the final result. You gain two things: - A cleaner main transcript. The click-by-click back-and-forth stays in the subagent's own context instead of filling your main agent's history. - Containment against prompt injection. The subagent carries only the six browser tools, so text it reads off a web page can never reach your main agent's connectors, files, or database. The subagent shares the parent's browser, so delegating does not open a second Chromium. ```ts // deep-agents/researcher/index.ts import { defineDeepAgent, browserSubagent } from '@stackbone/sdk/deep'; export default defineDeepAgent({ name: 'researcher', model: 'anthropic/claude-sonnet-4.5', subagents: [browserSubagent({ model: 'anthropic/claude-haiku-4.5' })], }); ``` Pass `browserSubagent({ model })` to run the navigation loop on a cheaper or faster model than your main agent. Pass `browserSubagent({ allowedDomains })` to fence in where it may browse. Tell the parent agent to delegate in its own prompt, keyed `researcher`: "You research topics. Hand any web browsing to the browser subagent." The browser subagent brings its own instruction, so you never write one for it. ### Put the tools on the agent directly Spread `browserTools()` into the agent's own `tools` array when browsing _is_ the agent's job, so there is no separate context to protect. This is also the form to use when you want to pause on an individual browser action for approval. ```ts // deep-agents/navigator/index.ts import { defineDeepAgent, browserTools } from '@stackbone/sdk/deep'; export default defineDeepAgent({ name: 'navigator', model: 'anthropic/claude-sonnet-4.5', tools: [...browserTools({ allowedDomains: ['example.com'] })], interruptOn: { browser_act: true }, // pause for a human decision before each action }); ``` Approval works through the subagent too. A tool gated with `interruptOn` inside the `browser` subagent still pauses the whole run and shows up in the approvals inbox, the same as a top-level pause. See [Approvals](/docs/sdk/humans/approval) for the decision flow. ## Options Both helpers take the same core options. ### `browserTools(options?)` | Option | Type | Default | What it does | | ---------------- | ------------------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `model` | `string` | `anthropic/claude-haiku-4.5` | The model the page-reading tools (`browser_act`, `browser_extract`, `browser_observe`) use, via OpenRouter. Trades cost for accuracy. `browser_goto` and `browser_screenshot` ignore it. | | `extractSchema` | Zod or JSON Schema | none | The output shape for `browser_extract`, so the tool returns typed data instead of raw HTML. Without it, `browser_extract` returns whatever the instruction implies. | | `allowedDomains` | `string[]` | allow all | The hosts the agent may browse. See [Restrict where the agent can browse](#restrict-where-the-agent-can-browse). | ```ts import { z } from 'zod'; import { browserTools } from '@stackbone/sdk/deep'; // browser_extract now returns typed data matching this shape. const tools = browserTools({ model: 'anthropic/claude-sonnet-4.5', extractSchema: z.object({ title: z.string(), price: z.number() }), allowedDomains: ['example.com'], }); ``` ### `browserSubagent(options?)` | Option | Type | Default | What it does | | ---------------- | ----------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `model` | `string` or a LangChain model | inherits the main agent | The model the subagent's own reasoning loop uses. A bare id string routes through OpenRouter; a built model instance passes through as is. | | `allowedDomains` | `string[]` | allow all | Passed into the bundled `browserTools({ allowedDomains })`, so the subagent restricts hosts the same way as the direct-tools form. | ## Restrict where the agent can browse By default an agent may open any `http` or `https` page, which is the right default for a local development tool. In production you want a fence. Set `allowedDomains` and the tools fail closed: - `browser_goto` refuses to open a host that is not on the list, and re-checks the page it landed on after any redirect. A redirect injected by a page cannot smuggle the agent onto an off-list host. - `browser_act`, `browser_extract`, `browser_observe`, and `browser_screenshot` refuse to act on, read, or capture a page whose current host is off the list. - The tools always refuse non-web schemes like `javascript:`, `data:`, and `file:`, whatever the list says. A host matches by exact name or by a real subdomain. `example.com` allows `example.com` and `app.example.com`, but not `notexample.com` or `example.com.evil.com`. ### Tighten the allowlist per install An operator can narrow the allowlist without a code change. Declare an `allowedDomains` field in your [config schema](/docs/sdk/platform/config), and a non-empty value set in Studio wins over the list you passed in code. An empty or unset config value falls back to the code list, so a blank field never widens your fence. ### Pre-check a URL yourself `checkDomainAllowed(url, allowlist)` is the same rule the tools use, exported so you can validate a URL before you pass it on. It does no network calls and needs no browser: ```ts import { checkDomainAllowed } from '@stackbone/sdk/deep'; const ok = checkDomainAllowed('https://app.example.com/login', ['example.com']); // ok.allowed === true const blocked = checkDomainAllowed('javascript:alert(1)', ['example.com']); // blocked.allowed === false; blocked.reason explains why ``` ## One browser per session The runtime keeps one browser per session. The first browser tool call in a run launches Chromium and caches it. Every later call in the same run reuses that same page, with its cookies and current tab intact. That reuse keeps the agent logged in across many turns and across a pause for human approval. - A **chat conversation** keeps its browser across its turns and across a pause, keyed by its durable session. - A **workflow-triggered run** gets its own browser, so two runs happening at once never share one Chromium's cookies. - A **subagent** reuses its parent's browser, so delegating to the `browser` subagent does not open a second one. A pooled browser can still die mid-conversation: a remote session hits its server-side timeout, a local Chromium crashes, a debugging target closes. The runtime checks the cached browser before it hands it back, and drops it when a call fails on a closed session. The next tool call opens a fresh browser under the same key with the saved sign-in sessions injected again, so the conversation carries on already authenticated. The one call that hit the dead browser still returns `{ "ok": false, "error": "..." }` rather than crashing the run. ## Choose which browser the agent drives An agent can drive one of three browsers. The operator picks which one in Studio, under **Browser settings**, on the **Configuration** tab. That screen is the way in: it holds the choice and the credentials for the whole installation, and it works on a deployed box nobody signs into over a shell. | Mode | What it opens | What the screen asks for | | --------------------------- | ------------------------------------------------------------------------------------ | --------------------------- | | Local browser | A plain Chromium on the box. No credentials, but many sites block it as a bot. | Nothing | | Managed remote browser | A Browserbase session. Fingerprint-clean, residential proxies, solves captchas. | Project identifier, API key | | Attach over a debugging URL | A browser that is already running somewhere and exposes a remote debugging endpoint. | The debugging URL | Press **Test connection** before you save. It resolves the local browser binary, checks the API key against the project, or asks the debugging endpoint for its version, depending on the mode you picked. Test needs every field of that mode filled in, because it probes what you typed rather than what is stored. A save touches only the fields of the mode you are saving. Switching to the local browser therefore leaves the managed provider's API key alone, and only the vendor's dashboard could issue that key again. Inside the mode you are saving, an empty field is a deletion: that is how you clear a stored value. An explicit mode wins over any inference. If you pick the managed remote browser and one credential is missing, the tool fails with a message naming the missing field and the screen to fix it in. It does not fall back to a local Chromium that the site you were trying to reach will block. Leave the mode unset and the runtime infers one from whichever credentials it finds, in this order: debugging URL, managed remote browser, local. The tools read this configuration each time they open a browser. A change on the screen applies to the next browser session, with no agent restart. ### Override it from the shell in local development On a deployed installation these four fields are stored secrets, and Browser settings is the only screen that writes them. The runtime also reads every one of them from the process environment, and the environment wins over the screen. That is the local development path: a variable exported in the shell that runs `stackbone dev` overrides the installation without touching the screen. The screen tells you when it detects one, so a saved change that appears to do nothing has a visible reason. | Variable | Effect | | ----------------------------------- | ----------------------------------------------------------------------------------------------------- | | `BROWSER_MODE` | The mode: `local`, `browserbase`, or `cdp`. Unset means "infer from whichever credentials exist". | | `BROWSERBASE_API_KEY` | API key for the managed remote browser. | | `BROWSERBASE_PROJECT_ID` | Project identifier for the managed remote browser, taken from its dashboard URL. | | `STACKBONE_BROWSER_CDP_URL` | Attach to a browser that is already running, over the Chrome DevTools Protocol. | | `STACKBONE_BROWSERBASE_KEEP_ALIVE` | Set to `0` to opt out of keeping the remote session alive. Needed on a Browserbase free plan. | | `STACKBONE_BROWSER_EXECUTABLE_PATH` | Pin the Chromium or Chrome binary the local browser launches, when the detected one is wrong. | | `STACKBONE_BROWSER_HEADED=1` | Open a visible window instead of running headless, so you can watch a `stackbone dev` browsing agent. | A `BROWSER_MODE` that is none of the three values is refused, not guessed. The tool fails naming the value it found and the three it accepts. Surrounding spaces and casing are forgiven, nothing else is. Without that check, a hand-typed `remote` in a compose file would put the installation back on a local Chromium with no warning. The first three names, and any name starting with `STACKBONE_`, are reserved. Studio's plain **Secrets** screen refuses to create, overwrite, or delete them, the same way it protects the model provider credential. Browser settings is the only screen that writes them. The box stores them encrypted and never shows them back to you. If you created them by hand before they were reserved, they keep working: the box adopts them on upgrade. ## Get past a captcha with the managed remote browser A headless local Chromium is easy for a site to spot, so `browser_login` can stall on a sign-in page guarded by a captcha. The managed remote browser gets through: it runs a real, fingerprint-clean browser behind residential proxies and solves captchas for you. Open **Browser settings**, pick the managed remote browser, paste the project identifier and the API key from the Browserbase dashboard, press **Test connection**, and save. The next browser session runs remote. Only the browser moves. The page-reading tools still reason through your deployment's model provider. Saved sign-in sessions keep working too: the agent reuses a login it solved once, captcha included. ## Watch the agent browse When the agent runs on the managed remote browser, the runtime streams the live session into the chat while the agent navigates. The Studio playground and an embedded chat surface pick it up on their own and show the running browser, so you watch it click and type in real time. The panel appears when the browser opens mid-turn and disappears when the turn ends. The runtime stores nothing, so a dead session cannot linger. There is nothing to wire in your agent. The local browser and the debugging-URL attach expose no hosted view, so the panel only shows up on the managed remote browser. ## What's next - [Agent examples](/docs/examples/agents/plain-greeter): three complete agents you can copy as a starting point. - [Stackbone Connect](/docs/home/features/integrations): the other way an agent reaches the outside world, through brokered connectors. - [Approvals](/docs/sdk/humans/approval): gate a browser action behind a human decision. - [Config](/docs/sdk/platform/config): declare the `allowedDomains` field an operator tightens per install.