Browser tools
browserTools()andbrowserSubagent()from@stackbone/sdk/deepgive 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). 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 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:
pnpm add @browserbasehq/stagehand@3.6.0 playwright@1.59.1
pnpm exec playwright install chromiumIf 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.
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.
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 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. |
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_gotorefuses 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, andbrowser_screenshotrefuse 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:, andfile:, 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, 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:
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 whyOne 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
browsersubagent 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: three complete agents you can copy as a starting point.
- Stackbone Connect: the other way an agent reaches the outside world, through brokered connectors.
- Approvals: gate a browser action behind a human decision.
- Config: declare the
allowedDomainsfield an operator tightens per install.