| Recipe | Shows |
|---|---|
| claude-code-mcp | Claude Code with one isolated, wiped browser per task |
| cursor-mcp | Cursor's agent opening pages in Browz, with a project rule |
| openai-agents-hosted-mcp | An OpenAI Agents SDK agent on the hosted server with an agent token |
| playwright-cdp | Existing Playwright code inside a Browz profile |
| puppeteer-cdp | Existing Puppeteer code inside a Browz profile |
| typescript-sdk-proxy | A profile behind a proxy: create, launch, screenshot, stop |
| python-rest-api | The whole loop from Python with the standard library |
| two-accounts-side-by-side | Two accounts a site allows, signed in at once, kept apart |
| human-handoff | The agent hands the window to you for a sign-in, then carries on |
| stored-sign-in | The agent signs in with a password and one-time code stored in Browz, and never sees them |
| headless-scheduled | A scheduled job on a server: one wiped session per page |
Every recipe was run against Browz 0.1.8 on 12 November 2026, on our own demo pages and example.com, unless its “Checked” line says otherwise. Install Browz first (download). The Local API and the local MCP server read the app’s own token, so no recipe asks you to paste one.
Use Browz only where each site’s terms allow it and within the acceptable use policy. When a page needs a person, such as a CAPTCHA or a sign-in, the agent asks you to finish it, and your passwords stay in Browz.
Claude Code + Browz MCP: one isolated browser per task
local MCP (stdio), browz_session_open · Checked: ran (Claude Code 2.1)
.mcp.jsonadds the Browz MCP server to this project (Claude Code reads it from the project root).CLAUDE.mdtells Claude Code how to browse: open abrowz_session_opensession per task, read withbrowz_snapshot, act withbrowz_act, hand sign-ins to you withbrowz_request_human, and close the session withbrowz_session_close.try-it.shruns a sample prompt non-interactively, with only the Browz tools allowed.
Copy .mcp.json and the CLAUDE.md section into your own project, or add the server for every project with:
claude mcp add browz -- npx -y @browz/mcp
What you need
- The Browz app, installed and open (gobrowz.com/download). The MCP server reads the app's own Local API token from its data folder, so no token goes in any config.
- A Browz account is optional for local use: this recipe works while the app shows its sign-in screen. An account adds sync across computers, teams and the hosted MCP server for cloud agents.
- Claude Code and Node.js 20 or later (for
npx). Without Node, pointcommandat the binary inside the app instead, e.g./Applications/Browz.app/Contents/Resources/browz-mcpon macOS.
Run it
cd claude-code-mcp
./try-it.sh
claude-code-mcp/.mcp.json
{
"mcpServers": {
"browz": {
"command": "npx",
"args": ["-y", "@browz/mcp"]
}
}
}
claude-code-mcp/CLAUDE.md
## Browsing with Browz
Use the Browz MCP tools (`browz_*`) for every web page in this project.
- One task, one isolated browser: start each task with `browz_session_open` (`task` says what you are
doing; `mode` stays `ephemeral` unless the task names a returning identity) and end it with
`browz_session_close`. Pass the returned `profileId` as `id` to the page tools. Never reuse another
task's session.
- Read a page with `browz_snapshot` and act with `browz_act` on the refs it returns (`e12`). Take a new
snapshot after every navigation. Use `browz_screenshot` only to check layout or images.
- If a snapshot says `NEEDS A PERSON` (a sign-in, a verification code or a CAPTCHA), call
`browz_request_human` with a short reason and wait. Never type a password or code, and never try to
solve a CAPTCHA.
- Only visit sites whose terms allow automated use. If a page says automated access is not allowed,
stop and say so.
Good to know
- Your plan sets how many sessions run at once (2 on Free, 5 on Pro 20, and more on larger plans; see sessions). Close sessions you are done with; an ephemeral session left open is wiped after
ttlMinutes(default one day). - A session with no proxy uses this computer's IP. Pass
"proxy": "pool"(or aproxyId) tobrowz_session_opento give each task its own exit. - A person completes CAPTCHAs; Browz never solves them. When a page asks for a person, the agent hands the window to you.
- Use it only on sites whose terms allow automated access.
Cursor + Browz MCP
local MCP (stdio) · Checked: config only
.cursor/mcp.jsonadds the Browz MCP server to the project..cursor/rules/browz.mdcis a project rule the agent picks up for any task that opens a web page: onebrowz_session_opensession per task,browz_snapshotto read,browz_actto click and type,browz_request_humanfor sign-ins,browz_session_closeat the end.
What you need
- The Browz app, installed and open (gobrowz.com/download).
- Cursor, and Node.js 20 or later for
npx. Without Node, setcommandto the binary inside the app (/Applications/Browz.app/Contents/Resources/browz-mcpon macOS) andargsto[].
Run it
cursor-mcp/.cursor/mcp.json
{
"mcpServers": {
"browz": {
"command": "npx",
"args": ["-y", "@browz/mcp"]
}
}
}
cursor-mcp/.cursor/rules/browz.mdc
---
description: Use Browz for any task that opens a web page (checking a deploy, reproducing a bug, reading docs behind a sign-in)
alwaysApply: false
---
# Browsing with Browz
Open web pages only through the Browz MCP tools (`browz_*`), never with a shell command or a browser you start yourself.
- One task, one isolated browser: `browz_session_open` with a short `task`, then use the returned `profileId` as `id` in the page tools, and finish with `browz_session_close` (an ephemeral session is wiped then).
- To test a local dev server, open it by its `http://127.0.0.1:<port>` or `http://localhost:<port>` address.
- Read with `browz_snapshot`; act with `browz_act` on its refs (`e12`); take a new snapshot after each navigation; `browz_wait` for text or a URL instead of sleeping.
- `browz_screenshot` when the layout matters (a visual bug, a broken image).
- `NEEDS A PERSON` in a snapshot (sign-in, verification code, CAPTCHA): call `browz_request_human` and wait. Never type a password or code, never try to solve a CAPTCHA.
- Use only sites whose terms allow automated access.
Good to know
- The config is the standard MCP
mcpServersshape that Cursor documents, and the same server command runs end to end in claude-code-mcp. - Your plan sets how many sessions run at once (Free 2). Close sessions when the task is done.
- A person completes CAPTCHAs; Browz never solves them. Use it only on sites whose terms allow automated access.
OpenAI Agents SDK + hosted Browz MCP (agent token)
hosted MCP (Streamable HTTP) · Checked: ran --no-llm against serve
main.py connects MCPServerStreamableHttp to the hosted server with Authorization: Bearer $BROWZ_AGENT_TOKEN, gives the Browz tools to an Agent, and runs a task: create a profile, start it, open https://example.com, report the title, take a screenshot, end the session and delete the profile. --no-llm does the same steps as direct tool calls, so you can check the wiring without a model key.
The hosted server relays each call to the Browz app on your computer. The browser is your real profile there (same engine, fingerprint, proxy and storage); nothing runs in a hosted browser.
What you need
- The Browz app on your computer: open, signed in, with Settings › Account › Remote control on. Remote control needs a hardware security chip (Apple silicon or a T2 Mac, or TPM 2.0 on Windows and Linux).
- A Browz agent token: in the app, *Automate › Connect an AI agent*, or on your account page. It is shown once. Keep it in the environment, never in code.
- uv (or Python 3.10+ with
pip install openai-agents). OPENAI_API_KEYfor the model (not needed with--no-llm).
Run it
cd openai-agents-hosted-mcp
uv sync
export BROWZ_AGENT_TOKEN=... # from your secret store
uv run main.py --no-llm # the Browz steps only
export OPENAI_API_KEY=...
uv run main.py # the agent does the task
openai-agents-hosted-mcp/main.py
"""An OpenAI Agents SDK agent that drives Browz through the hosted MCP server, with an agent token.
uv run main.py # the agent (needs OPENAI_API_KEY and BROWZ_AGENT_TOKEN)
uv run main.py --no-llm # the same browser steps as direct tool calls, no model
uv run main.py --task "Open https://example.com and tell me the title, then end the session"
The hosted server (https://mcp.gobrowz.com/mcp, free public beta) relays each tool call to the
Browz app on YOUR computer, which must be open, signed in, with Remote control on. The browser is
your real Browz profile there; nothing runs in a cloud browser.
Environment:
BROWZ_AGENT_TOKEN a Browz agent token (app: Automate > Connect an AI agent, or the account
page). Read from the environment, never written in code.
BROWZ_MCP_URL default https://mcp.gobrowz.com/mcp. Any Browz Streamable HTTP endpoint works,
e.g. a `browz-mcp serve` on your own server with its bearer token.
BROWZ_DEVICE_ID which computer to use, when more than one is online (browz_devices_list).
OPENAI_API_KEY for the model (not needed with --no-llm).
OPENAI_MODEL default gpt-5.4-mini.
"""
import argparse
import asyncio
import json
import os
import sys
from agents import Agent, Runner
from agents.mcp import MCPServerStreamableHttp
MCP_URL = os.environ.get("BROWZ_MCP_URL", "https://mcp.gobrowz.com/mcp")
TASK = (
"Using Browz: create a new profile called agent-check, start it, open https://example.com, "
"tell me the page title, take a screenshot, then end the session and delete the profile."
)
INSTRUCTIONS = (
"You control web browsers through the Browz tools (browz_*). Read pages with browz_snapshot and "
"act on them with browz_act using the refs it returns. If a snapshot says NEEDS A PERSON (a "
"sign-in, a verification code or a CAPTCHA), call browz_request_human and never type a password "
"or try to solve a CAPTCHA yourself. Always end the sessions you start with browz_session_end."
)
def with_device(args):
"""Add deviceId when one is set: needed only when several computers are online."""
device = os.environ.get("BROWZ_DEVICE_ID")
return {**args, "deviceId": device} if device else args
def text_of(result):
return "\n".join(getattr(c, "text", "") for c in result.content if getattr(c, "type", "") == "text")
def first_json(text):
start = text.find("{")
return json.JSONDecoder().raw_decode(text[start:])[0]
async def direct(server):
"""The task's steps as plain tool calls through the same MCP connection."""
async def call(name, args):
result = await server.call_tool(name, with_device(args))
if getattr(result, "is_error", None) or getattr(result, "isError", None): # name differs by mcp version
raise RuntimeError(f"{name}: {text_of(result)}")
return result
pid = first_json(text_of(await call("browz_profile_create", {"name": "agent-check"})))["id"]
try:
await call("browz_profile_start", {"id": pid})
await call("browz_navigate", {"id": pid, "url": "https://example.com"})
snapshot = text_of(await call("browz_snapshot", {"id": pid}))
title = next((line[len("Title: "):] for line in snapshot.splitlines() if line.startswith("Title: ")), None)
shot = await call("browz_screenshot", {"id": pid})
images = [c for c in shot.content if getattr(c, "type", "") == "image"]
return {"profileId": pid, "title": title, "screenshotBytes": len(images[0].data) * 3 // 4 if images else 0}
finally:
await call("browz_session_end", {"id": pid, "delete": True})
async def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--no-llm", action="store_true", help="direct tool calls, no model")
parser.add_argument("--task", default=TASK)
args = parser.parse_args()
token = os.environ.get("BROWZ_AGENT_TOKEN")
if not token:
sys.exit("set BROWZ_AGENT_TOKEN to a Browz agent token (never paste it into code)")
server = MCPServerStreamableHttp(
name="browz",
params={"url": MCP_URL, "headers": {"Authorization": f"Bearer {token}"}, "timeout": 60},
cache_tools_list=True,
client_session_timeout_seconds=90, # a browser start can take longer than the SDK's default 5 s
)
async with server:
tools = [t.name for t in await server.list_tools()]
if args.no_llm:
out = await direct(server)
else:
agent = Agent(
name="Browz agent",
instructions=INSTRUCTIONS,
mcp_servers=[server],
model=os.environ.get("OPENAI_MODEL", "gpt-5.4-mini"),
)
result = await Runner.run(agent, args.task, max_turns=30)
out = {"answer": str(result.final_output)}
print(json.dumps({"ok": True, "mcp": MCP_URL, "tools": len(tools), **out}, indent=2))
if __name__ == "__main__":
asyncio.run(main())
Good to know
- The hosted server is a free public beta. On the Free plan an account gets 2 hosted sessions at once, 2,000 actions and 300 relay minutes a month, and 30 tool calls a minute per connection. When an allowance is used up, the tool result says how long to wait.
- The hosted server offers the profile, page and hand-off tools this recipe uses (
browz_navigate,browz_snapshot,browz_act,browz_screenshot,browz_request_human, …). Cookies, JavaScript (browz_evaluate), downloads andbrowz_session_openrun on the local server (claude-code-mcp). - Page snapshots, screenshots and typed text pass through our server encrypted and are deleted after 7 days; the action log (tool, profile, page origin and path, outcome) is kept 90 days.
- A person completes CAPTCHAs; Browz never solves them. Use it only where each site's terms allow automated access.
Playwright over CDP, attached to a Browz profile
Local API + CDP · Checked: ran
index.mjs creates a throwaway profile through the Local API (POST /api/v1/profile/create), starts it (POST /api/v1/browser/start), attaches Playwright with chromium.connectOverCDP(), opens a page, saves screenshot.png, detaches, stops the profile (POST /api/v1/browser/stop) and deletes and purges it. Set BROWZ_PROFILE_ID to use one of your own profiles instead; it is started and stopped, never deleted.
Two rules make the difference:
- Attach, never launch.
chromium.launch()would start a different browser with none of the profile's state. - Use the existing context (
browser.contexts()[0]). It is the profile.browser.newContext()would be an empty, incognito-like context.
What you need
- The Browz app, installed and open (gobrowz.com/download).
- Node.js 20 or later.
The script reads the app's Local API token from ~/.browz/api-token and the port from ~/.browz/agent.json (BROWZ_DATA for another data folder; BROWZ_TOKEN and BROWZ_API=host:port override both).
Run it
cd playwright-cdp
npm install
node index.mjs
BROWZ_TEST_URL=https://example.org/ node index.mjs
playwright-cdp/index.mjs
// Playwright attached over CDP to a Browz profile.
//
// npm install
// node index.mjs # a throwaway profile, created and removed again
// BROWZ_PROFILE_ID=<id> node index.mjs # an existing profile (started and stopped, never deleted)
//
// The rule that matters: ATTACH to the browser Browz started (chromium.connectOverCDP). Never
// chromium.launch(): that starts stock Chromium with its own fingerprint and none of the profile's
// cookies, proxy or storage.
import { readFile, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { chromium } from 'playwright-core';
const URL_TO_OPEN = process.env.BROWZ_TEST_URL || 'https://example.com/';
// ── Local API credentials: the Browz app writes its token to <data dir>/api-token ──────────────
const DATA = process.env.BROWZ_DATA || join(homedir(), '.browz');
const TOKEN = process.env.BROWZ_TOKEN || (await readFile(join(DATA, 'api-token'), 'utf8')).trim();
let HOST = process.env.BROWZ_API;
if (!HOST) {
let port = 50333;
try { port = JSON.parse(await readFile(join(DATA, 'agent.json'), 'utf8')).port || port; } catch { /* default port */ }
HOST = `127.0.0.1:${port}`;
}
/** One Local API call. Throws with the agent's own message on failure. */
async function api(path, body) {
const res = await fetch(`http://${HOST}${path}`, {
method: body === undefined ? 'GET' : 'POST',
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || (typeof json.code === 'number' && json.code !== 0)) {
throw new Error(`${path}: ${json.msg || json.error || `HTTP ${res.status}`}`);
}
return json;
}
// ── 1. A profile: your own, or a throwaway one ────────────────────────────────────────────────
const owned = !process.env.BROWZ_PROFILE_ID;
const profileId = process.env.BROWZ_PROFILE_ID
|| (await api('/api/v1/profile/create', { name: `recipe-playwright-${Date.now()}` })).data.id;
let browser;
let started = false;
try {
// ── 2. Start it through Browz. The reply carries the CDP endpoints. ───────────────────────────
const { data: session } = await api('/api/v1/browser/start', { id: profileId });
started = true;
// ── 3. Attach. Use the existing context: it IS the profile (cookies, storage). ───────────────
// browser.newContext() would make an incognito-like context without the profile's state.
browser = await chromium.connectOverCDP(session.wsEndpoint || session.cdpEndpoint, { timeout: 30_000 });
const context = browser.contexts()[0];
const page = context.pages()[0] || (await context.newPage());
// ── 4. Ordinary Playwright from here on. ───────────────────────────────────────────────────────
await page.goto(URL_TO_OPEN, { waitUntil: 'load', timeout: 30_000 });
const title = await page.title();
await writeFile('screenshot.png', await page.screenshot());
console.log(JSON.stringify({ ok: true, profileId, browser: browser.version(), url: page.url(), title, screenshot: 'screenshot.png' }));
} finally {
// ── 5. Detach (close() on a CDP connection does not kill Browz's browser), then stop through
// Browz so the profile is saved cleanly. A throwaway profile is deleted and purged.
if (browser) await browser.close().catch(() => {});
if (started) await api('/api/v1/browser/stop', { id: profileId }).catch((e) => console.error(String(e)));
if (owned) {
await api('/api/v1/profile/delete', { id: profileId });
await api('/api/v1/profile/purge', { id: profileId });
}
}
Good to know
playwright-coreis pinned to the version Browz reviews (1.61.1, see the automation compatibility contract).page.evaluate()runs script in the page. For reading and clicking, Playwright locators are fine; Browz's ownbrowz_snapshot/browz_act(MCP) and/api/v1/browser/snapshot/act(REST) read and act without running any script in the page.- Use it only on sites whose terms allow automated access. A person completes CAPTCHAs; Browz never solves them.
Puppeteer over CDP, attached to a Browz profile
Local API + CDP · Checked: ran
index.mjs creates a throwaway profile (POST /api/v1/profile/create), starts it (POST /api/v1/browser/start), connects with puppeteer.connect({ browserWSEndpoint }), opens a page in the profile's first tab, saves screenshot.png, disconnects, stops the profile (POST /api/v1/browser/stop) and deletes and purges it. BROWZ_PROFILE_ID uses one of your own profiles instead (never deleted).
puppeteer.connect(), neverpuppeteer.launch().defaultViewport: nullkeeps the profile's own window size. Puppeteer's default resizes every page to 800x600, which changes what the page sees.browser.disconnect()leaves the browser to Browz, which stops it and saves the profile.
What you need
- The Browz app, installed and open (gobrowz.com/download).
- Node.js 20 or later.
Credentials come from ~/.browz/api-token and ~/.browz/agent.json (BROWZ_DATA, BROWZ_TOKEN, BROWZ_API override them).
Run it
cd puppeteer-cdp
npm install
node index.mjs
puppeteer-cdp/index.mjs
// Puppeteer attached over CDP to a Browz profile.
//
// npm install
// node index.mjs # a throwaway profile, created and removed again
// BROWZ_PROFILE_ID=<id> node index.mjs # an existing profile (started and stopped, never deleted)
//
// Use puppeteer.connect() on the browser Browz started. Never puppeteer.launch(): that runs a
// different browser with none of the profile's fingerprint, proxy, cookies or storage.
import { readFile, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import puppeteer from 'puppeteer-core';
const URL_TO_OPEN = process.env.BROWZ_TEST_URL || 'https://example.com/';
// ── Local API credentials: the Browz app writes its token to <data dir>/api-token ──────────────
const DATA = process.env.BROWZ_DATA || join(homedir(), '.browz');
const TOKEN = process.env.BROWZ_TOKEN || (await readFile(join(DATA, 'api-token'), 'utf8')).trim();
let HOST = process.env.BROWZ_API;
if (!HOST) {
let port = 50333;
try { port = JSON.parse(await readFile(join(DATA, 'agent.json'), 'utf8')).port || port; } catch { /* default port */ }
HOST = `127.0.0.1:${port}`;
}
async function api(path, body) {
const res = await fetch(`http://${HOST}${path}`, {
method: body === undefined ? 'GET' : 'POST',
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || (typeof json.code === 'number' && json.code !== 0)) {
throw new Error(`${path}: ${json.msg || json.error || `HTTP ${res.status}`}`);
}
return json;
}
const owned = !process.env.BROWZ_PROFILE_ID;
const profileId = process.env.BROWZ_PROFILE_ID
|| (await api('/api/v1/profile/create', { name: `recipe-puppeteer-${Date.now()}` })).data.id;
let browser;
let started = false;
try {
// 1. Start through Browz.
const { data: session } = await api('/api/v1/browser/start', { id: profileId });
started = true;
// 2. Attach. browserWSEndpoint is the browser's ws:// URL; browserURL (http://127.0.0.1:<port>)
// works too. defaultViewport: null keeps the profile's own window and screen size, which are
// part of its fingerprint; Puppeteer's default would resize every page to 800x600.
browser = session.wsEndpoint
? await puppeteer.connect({ browserWSEndpoint: session.wsEndpoint, defaultViewport: null })
: await puppeteer.connect({ browserURL: session.cdpEndpoint, defaultViewport: null });
// 3. Reuse the profile's first tab.
const page = (await browser.pages())[0] || (await browser.newPage());
await page.goto(URL_TO_OPEN, { waitUntil: 'load', timeout: 30_000 });
const title = await page.title();
await writeFile('screenshot.png', await page.screenshot());
console.log(JSON.stringify({ ok: true, profileId, browser: await browser.version(), url: page.url(), title, screenshot: 'screenshot.png' }));
} finally {
// 4. disconnect() leaves the browser running; Browz stops it and saves the profile.
if (browser) await browser.disconnect().catch(() => {});
if (started) await api('/api/v1/browser/stop', { id: profileId }).catch((e) => console.error(String(e)));
if (owned) {
await api('/api/v1/profile/delete', { id: profileId });
await api('/api/v1/profile/purge', { id: profileId });
}
}
Good to know
puppeteer-coreis pinned to the reviewed version (25.5.0).page.evaluate()andpage.$()run script in the page; prefer plain navigation and input where you can.- Use it only on sites whose terms allow automated access. A person completes CAPTCHAs; Browz never solves them.
TypeScript SDK: a profile behind a proxy, launched, screenshotted, stopped
@browz/sdk · Checked: ran, type-checked
index.ts:
- reads the Browz app's credentials with
localCredentials(); - adds your proxy to the pool with
browz.proxies.create()(the password is write-only); - creates a profile on it with
browz.profiles.create({ proxyId }); its timezone and languages follow the proxy's exit; - starts it with
browz.browser.start()and reports anycoherenceWarnings; - opens a page with
browz.browser.navigate(); - saves a screenshot through
POST /api/v1/browser/screenshot, with the same credentials; - stops the profile, deletes and purges it, and removes the proxy it added.
What you need
- The Browz app, installed and open (gobrowz.com/download).
- Node.js 22.18 or later (it runs
.tsfiles directly), or compile withtsc. - A proxy you are allowed to use, as a URL:
http://user:pass@host:port(https,socks4andsocks5work too). Without one, the profile runs on this computer's IP.
Run it
cd typescript-sdk-proxy
npm install
npm run typecheck
BROWZ_PROXY='http://user:[email protected]:8000' npm start
typescript-sdk-proxy/index.ts
// The TypeScript SDK: add a proxy, create a profile behind it, launch it, take a screenshot, stop.
//
// npm install
// BROWZ_PROXY='http://user:[email protected]:8000' npm start
// npm run typecheck
//
// BROWZ_PROXY is any proxy you are allowed to use: http, https, socks4 or socks5. Without it the
// profile runs direct (this machine's IP), which links it to every other unproxied profile.
import { writeFile } from 'node:fs/promises';
import { Browz, BrowzError, localCredentials, type ProxyScheme } from '@browz/sdk';
const URL_TO_OPEN = process.env.BROWZ_TEST_URL ?? 'https://example.com/';
/** `scheme://user:pass@host:port` → the SDK's ProxySpec. */
function parseProxy(raw: string) {
const u = new URL(raw);
const scheme = u.protocol.replace(':', '') as ProxyScheme;
if (!['http', 'https', 'socks4', 'socks5'].includes(scheme)) throw new Error(`unsupported proxy scheme ${scheme}`);
return {
scheme,
host: u.hostname,
port: Number(u.port),
...(u.username ? { user: decodeURIComponent(u.username) } : {}),
...(u.password ? { pass: decodeURIComponent(u.password) } : {}),
};
}
// Reads the Browz app's own Local API token (<BROWZ_DATA or ~/.browz>/api-token) and port.
const creds = await localCredentials();
const browz = new Browz(creds);
// 1. A pool proxy. Its id is what profiles (and the MCP tools) refer to; the password is
// write-only and never returned by any list.
let proxyId: string | undefined;
if (process.env.BROWZ_PROXY) {
proxyId = (await browz.proxies.create({ name: `recipe-proxy-${Date.now()}`, ...parseProxy(process.env.BROWZ_PROXY) })).id;
} else {
console.error('BROWZ_PROXY is not set: this profile will use this machine\'s own IP.');
}
// 2. A profile behind it. Timezone and languages follow the proxy's exit unless you set them.
const profile = await browz.profiles.create({ name: `recipe-sdk-${Date.now()}`, ...(proxyId ? { proxyId } : {}) });
let started = false;
try {
// 3. Launch. coherenceWarnings flags anything that contradicts the exit (e.g. a timezone).
const session = await browz.browser.start(profile.id);
started = true;
// 4. Open a page through the agent and wait for it to load.
const nav = await browz.browser.navigate(profile.id, URL_TO_OPEN);
// 5. Screenshot. The published SDK (0.2.0) has no wrapper for it yet, so this is the Local API route
// itself (POST /api/v1/browser/screenshot, PNG as base64), with the same credentials.
// @browz/sdk 0.3.0 adds browser.snapshot/act/wait/screenshot; this recipe switches to them
// once 0.3.0 is on npm.
const res = await fetch(`http://${creds.host}/api/v1/browser/screenshot`, {
method: 'POST',
headers: { Authorization: `Bearer ${creds.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ id: profile.id }),
});
const shot = (await res.json()) as { code: number; msg?: string; data?: { base64: string } };
if (!res.ok || shot.code !== 0 || !shot.data) throw new Error(`screenshot: ${shot.msg ?? res.status}`);
await writeFile('screenshot.png', Buffer.from(shot.data.base64, 'base64'));
console.log(JSON.stringify({
ok: true,
profileId: profile.id,
archetype: profile.archetype,
proxyId: proxyId ?? null,
cdpEndpoint: session.cdpEndpoint,
coherenceWarnings: session.coherenceWarnings ?? [],
url: nav.url,
loaded: nav.loaded,
screenshot: 'screenshot.png',
}));
} catch (e) {
// BrowzError carries the agent's own message and HTTP status.
if (e instanceof BrowzError) console.error(`Browz refused: ${e.message} (HTTP ${e.status})`);
throw e;
} finally {
// 6. Stop (saves the profile cleanly), then remove what this recipe made.
if (started) await browz.browser.stop(profile.id);
await browz.profiles.delete(profile.id);
await browz.profiles.purge(profile.id);
if (proxyId) await browz.proxies.delete(proxyId);
}
Good to know
- A proxy is checked when the profile starts; a dead or refusing proxy makes the start fail with Browz's message (caught as
BrowzError). cdpEndpointis there for Playwright or Puppeteer (see playwright-cdp).- Use it only on sites whose terms allow automated access. A person completes CAPTCHAs; Browz never solves them.
Python and the Local API, standard library only
Local API (REST) · Checked: ran
browz_rest.py uses urllib to:
| Step | Route |
|---|---|
| create a profile | POST /api/v1/profile/create |
| start its browser | POST /api/v1/browser/start |
| open a page and wait for load | POST /api/v1/browser/navigate |
| read it as an accessibility outline with element refs | POST /api/v1/browser/snapshot |
| save a PNG | POST /api/v1/browser/screenshot |
| stop, then delete and purge | POST /api/v1/browser/stop, /api/v1/profile/delete, /api/v1/profile/purge |
To click and type, pass a ref from the snapshot (e18) to POST /api/v1/browser/act, as two-accounts-side-by-side does. The full API is in the OpenAPI 3.1 file: gobrowz.com/openapi/local-api.json.
What you need
- The Browz app, installed and open (gobrowz.com/download).
- Python 3.9 or later.
The token is read from ~/.browz/api-token and the port from ~/.browz/agent.json (BROWZ_DATA, BROWZ_TOKEN, BROWZ_API=host:port override them). Every route except GET /status needs Authorization: Bearer <token>.
Run it
cd python-rest-api
python3 browz_rest.py
python3 browz_rest.py --url https://example.org/ --out page.png --keep
python-rest-api/browz_rest.py
"""Drive a Browz profile from Python through the Local API, with the standard library only.
python3 browz_rest.py # a throwaway profile, removed afterwards
python3 browz_rest.py --url https://example.com/ --keep
Create a profile, start it, open a page, read it as an accessibility snapshot, take a screenshot,
stop it, then delete and purge the profile. No browser driver, no pip install: every step is one
HTTP call to the Browz agent on this computer (OpenAPI: https://gobrowz.com/openapi/local-api.json).
Credentials: BROWZ_TOKEN and BROWZ_API (host:port) when set, otherwise the token in
<BROWZ_DATA or ~/.browz>/api-token and the port in agent.json (default 50333).
"""
import argparse
import base64
import json
import os
import pathlib
import time
import urllib.error
import urllib.request
DATA = pathlib.Path(os.environ.get("BROWZ_DATA") or pathlib.Path.home() / ".browz")
def credentials():
token = os.environ.get("BROWZ_TOKEN") or (DATA / "api-token").read_text().strip()
host = os.environ.get("BROWZ_API")
if not host:
port = 50333
try:
port = json.loads((DATA / "agent.json").read_text()).get("port") or port
except (OSError, ValueError):
pass
host = f"127.0.0.1:{port}"
return host, token
HOST, TOKEN = credentials()
def api(path, body=None, timeout=120):
"""One Local API call: GET without a body, POST with one. Raises with the agent's message."""
req = urllib.request.Request(
f"http://{HOST}{path}",
data=None if body is None else json.dumps(body).encode(),
method="GET" if body is None else "POST",
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as res:
reply = json.loads(res.read() or b"{}")
except urllib.error.HTTPError as e:
reply = json.loads(e.read() or b"{}")
raise RuntimeError(f"{path}: HTTP {e.code}: {reply.get('msg') or reply.get('error')}") from None
if reply.get("code", 0) != 0:
raise RuntimeError(f"{path}: {reply.get('msg') or reply}")
return reply
def main():
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument("--url", default="https://example.com/")
p.add_argument("--keep", action="store_true", help="keep the profile afterwards")
p.add_argument("--out", default="screenshot.png")
args = p.parse_args()
profile = api("/api/v1/profile/create", {"name": f"recipe-python-{int(time.time())}"})["data"]
pid = profile["id"]
started = False
try:
start = api("/api/v1/browser/start", {"id": pid})["data"]
started = True
nav = api("/api/v1/browser/navigate", {"id": pid, "url": args.url})["data"]
snap = api("/api/v1/browser/snapshot", {"id": pid, "mode": "compact", "maxTokens": 2000})["data"]
shot = api("/api/v1/browser/screenshot", {"id": pid})["data"]
pathlib.Path(args.out).write_bytes(base64.b64decode(shot["base64"]))
print(json.dumps({
"ok": True,
"profileId": pid,
"debugPort": start["debugPort"],
"url": nav.get("url"),
"title": snap["title"],
"snapshotRefs": snap["refs"],
"snapshotFirstLines": snap["snapshot"].splitlines()[:5],
"screenshot": args.out,
}, indent=2, ensure_ascii=False))
finally:
if started:
api("/api/v1/browser/stop", {"id": pid})
if not args.keep:
api("/api/v1/profile/delete", {"id": pid})
api("/api/v1/profile/purge", {"id": pid})
if __name__ == "__main__":
main()
Good to know
- The Local API listens on loopback only (127.0.0.1); run the script on the same computer as Browz.
- Give a script less than the owner's token: a local agent key (
blcl_…) limits it to some profiles, sites and scopes (Browz app: Settings › AI agents). - Use it only on sites whose terms allow automated access. A person completes CAPTCHAs; Browz never solves them.
Two accounts, side by side, kept apart
Local API snapshot + act · Checked: ran
index.mjs serves a small demo shop on 127.0.0.1 (a form that sets a session cookie, and an account page), then:
- creates two profiles, one per account (optionally each on its own proxy);
- starts both;
- signs each one in at the same time, the way a person would:
POST /api/v1/browser/snapshotto read the form,POST /api/v1/browser/actto fill the field and click the button,POST /api/v1/browser/waituntil the page says who is signed in; - opens the account page in both and checks that each still shows only its own account;
- stops, deletes and purges both profiles.
No script runs in the page: snapshot reads the accessibility tree and act sends real input events.
What you need
- The Browz app, installed and open (gobrowz.com/download).
- Node.js 20 or later. No packages to install.
- Optional: two proxies in your Browz pool (
BROWZ_PROXY_A,BROWZ_PROXY_B, their pool ids). Without them both profiles share this computer's IP, which a site can use to link them.
Run it
cd two-accounts-side-by-side
node index.mjs
BROWZ_PROXY_A=px… BROWZ_PROXY_B=px… node index.mjs
two-accounts-side-by-side/index.mjs
// Two accounts, kept apart: two Browz profiles sign in to the same site side by side, each with
// its own cookies and storage, driven only through the Local API's page model (snapshot + act).
//
// node index.mjs # against the small demo site this script serves on 127.0.0.1
// BROWZ_PROXY_A=<pool id> BROWZ_PROXY_B=<pool id> node index.mjs # each profile on its own exit
//
// Use this pattern only on sites whose terms allow you to hold more than one account (many do, for
// example separate business and personal accounts, or one per client you manage). The demo site
// here is ours and exists for this recipe.
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
// ── Local API credentials ────────────────────────────────────────────────────────────────────
const DATA = process.env.BROWZ_DATA || join(homedir(), '.browz');
const TOKEN = process.env.BROWZ_TOKEN || (await readFile(join(DATA, 'api-token'), 'utf8')).trim();
let HOST = process.env.BROWZ_API;
if (!HOST) {
let port = 50333;
try { port = JSON.parse(await readFile(join(DATA, 'agent.json'), 'utf8')).port || port; } catch { /* default port */ }
HOST = `127.0.0.1:${port}`;
}
async function api(path, body) {
const res = await fetch(`http://${HOST}${path}`, {
method: body === undefined ? 'GET' : 'POST',
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || (typeof json.code === 'number' && json.code !== 0)) {
throw new Error(`${path}: ${json.msg || json.error || `HTTP ${res.status}`}`);
}
return json;
}
// ── The demo site: a sign-in form that sets a session cookie, and an account page ──────────────
const page = (title, body) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title></head><body>${body}</body></html>`;
function demoSite() {
const server = http.createServer((req, res) => {
const user = /(?:^|;\s*)demo_user=([a-z0-9-]+)/.exec(req.headers.cookie || '')?.[1];
if (req.method === 'POST' && req.url === '/login') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
const name = (new URLSearchParams(body).get('name') || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 32) || 'anonymous';
res.writeHead(303, { 'Set-Cookie': `demo_user=${name}; Path=/; HttpOnly; SameSite=Lax`, Location: '/account' });
res.end();
});
return;
}
if (req.url === '/account') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(page('Account', user ? `<h1>Signed in as ${user}</h1>` : '<h1>Not signed in</h1><a href="/login">Sign in</a>'));
return;
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(page('Demo sign-in', `<h1>Demo shop</h1><form method="post" action="/login">
<label>Account name <input name="name" autocomplete="off"></label>
<button type="submit">Continue</button></form>`));
});
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)));
}
/** The ref of the first snapshot line with this role and accessible name, e.g. `textbox "Account name" [e5]`. */
function refOf(snapshot, role, name) {
const line = snapshot.split('\n').find((l) => l.includes(`${role} "${name}`) && /\[e\d+\]/.test(l));
if (!line) throw new Error(`no ${role} "${name}" in the snapshot:\n${snapshot}`);
return /\[(e\d+)\]/.exec(line)[1];
}
/** Sign one profile in as `name` the way a person would: read the page, fill the field, click. */
async function signIn(id, base, name) {
await api('/api/v1/browser/navigate', { id, url: `${base}/login` });
const snap = (await api('/api/v1/browser/snapshot', { id, mode: 'interactive' })).data.snapshot;
await api('/api/v1/browser/act', { id, action: 'fill', ref: refOf(snap, 'textbox', 'Account name'), text: name });
await api('/api/v1/browser/act', { id, action: 'click', ref: refOf(snap, 'button', 'Continue') });
const waited = (await api('/api/v1/browser/wait', { id, text: `Signed in as ${name}`, timeoutMs: 15_000 })).data;
if (!waited.matched) throw new Error(`${name}: sign-in did not finish`);
}
async function whoIsSignedIn(id, base) {
await api('/api/v1/browser/navigate', { id, url: `${base}/account` });
const { title, snapshot } = (await api('/api/v1/browser/snapshot', { id })).data;
return { title, heading: /heading "([^"]+)"/.exec(snapshot)?.[1] ?? null };
}
const site = await demoSite();
const base = `http://127.0.0.1:${site.address().port}`;
const accounts = [
{ label: 'alice', proxyId: process.env.BROWZ_PROXY_A },
{ label: 'bob', proxyId: process.env.BROWZ_PROXY_B },
];
const made = [];
try {
// One profile per account. Each has its own seed (fingerprint), folder (cookies, storage) and,
// when you pass one, its own proxy. Two profiles without proxies share this machine's IP.
for (const a of accounts) {
const { data } = await api('/api/v1/profile/create', { name: `recipe-account-${a.label}-${Date.now()}`, ...(a.proxyId ? { proxyId: a.proxyId } : {}) });
made.push({ ...a, id: data.id });
}
await Promise.all(made.map((a) => api('/api/v1/browser/start', { id: a.id })));
// Both sign in at the same time, in their own browsers.
await Promise.all(made.map((a) => signIn(a.id, base, a.label)));
// Each profile still sees only its own account: the cookie lives in that profile alone.
const seen = await Promise.all(made.map(async (a) => ({ profile: a.id, expected: a.label, ...(await whoIsSignedIn(a.id, base)) })));
const ok = seen.every((s) => s.heading === `Signed in as ${s.expected}`);
console.log(JSON.stringify({ ok, site: base, accounts: seen }, null, 2));
process.exitCode = ok ? 0 : 1;
} finally {
for (const a of made) {
await api('/api/v1/browser/stop', { id: a.id }).catch(() => {});
await api('/api/v1/profile/delete', { id: a.id }).catch(() => {});
await api('/api/v1/profile/purge', { id: a.id }).catch((e) => console.error(String(e)));
}
site.close();
}
Good to know
- Only where the site allows it. Many sites limit how many accounts one person may hold, or forbid automated sign-ups. Browz's acceptable use policy forbids creating accounts a platform forbids, fake-account networks, and returning to a service after a ban.
- Separate cookies and storage are guaranteed by the profile folders. Whether a site can still link two accounts depends on more than the browser: your IP (give each profile its own proxy), payment details, phone numbers and behaviour.
- To turn this into an agent task, use
browz_session_openwith apersistentmode and oneidentityper account over MCP: each identity returns to its own profile, cookies and proxy.
Human in the loop: the agent asks you to sign in, then carries on
local MCP, browz_request_human · Checked: ran (headed Mac)
index.mjs is a small MCP client (the official @modelcontextprotocol/sdk) that runs the local Browz MCP server over stdio and plays the agent's part against a demo sign-in page it serves on 127.0.0.1:
browz_profile_create, thenbrowz_profile_startwithheadless: false(you need a window);browz_navigateto the sign-in page andbrowz_snapshot: Browz flags it withNEEDS A PERSON;browz_request_humanwith a reason,kind: "login",timeoutSec: 300anddoneWhenUrlContains: "/dashboard". Browz pauses automation on the profile, brings its window to the front, shows a notification and a banner with I'm done and Cancel. The call waits, sending progress, until you finish;- a new
browz_snapshot: the agent is signed in ("Welcome back") without ever seeing the password; browz_session_endwithdelete: true. That moves the demo profile to the trash, where it stays (restorable) until you empty the trash in Browz; see "Throwaway profiles and the trash" indocs/agents/sessions.md.
The same flow works with any MCP agent: tell it "if a snapshot says NEEDS A PERSON, call browz_request_human" (see the CLAUDE.md in claude-code-mcp).
What you need
- The Browz app, installed and open, on the computer you are sitting at (gobrowz.com/download).
- Node.js 20 or later.
Run it
cd human-handoff
npm install
node index.mjs
human-handoff/index.mjs
// Human in the loop: the agent drives a Browz profile through MCP, meets a sign-in page, hands the
// live window to the person at the computer (browz_request_human), waits, and then carries on in
// the same session, signed in. The agent never sees, types or asks for the password.
//
// npm install
// node index.mjs # against the demo sign-in page this script serves on 127.0.0.1
//
// The profile must run WITH a window (a person has to see it), so run this on the computer where
// the Browz app is open, not under `browz-mcp serve` (its profiles are headless and a handoff is
// refused there). Browz shows a notification and a banner with "I'm done"; this demo also finishes
// by itself when the page reaches /dashboard (doneWhenUrlContains).
//
// BROWZ_MCP the browz-mcp binary to run (default: npx -y @browz/mcp, which finds the app's)
import http from 'node:http';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js';
// ── Demo site: a sign-in form (/login) and a page behind it (/dashboard) ───────────────────────
const html = (title, body) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title></head><body>${body}</body></html>`;
const site = http.createServer((req, res) => {
const signedIn = /(?:^|;\s*)demo_session=ok/.test(req.headers.cookie || '');
if (req.method === 'POST' && req.url === '/login') {
req.resume();
req.on('end', () => { res.writeHead(303, { 'Set-Cookie': 'demo_session=ok; Path=/; HttpOnly; SameSite=Lax', Location: '/dashboard' }); res.end(); });
return;
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
if (req.url === '/dashboard' && signedIn) return res.end(html('Dashboard', '<h1>Welcome back</h1><p>Your orders: 3 open.</p>'));
res.end(html('Sign in', `<h1>Sign in to the demo shop</h1><form method="post" action="/login">
<label>Email <input name="email" type="email" autocomplete="username"></label>
<label>Password <input name="password" type="password" autocomplete="current-password"></label>
<button type="submit">Sign in</button></form>`));
});
await new Promise((r) => site.listen(0, '127.0.0.1', r));
const BASE = `http://127.0.0.1:${site.address().port}`;
// ── The local Browz MCP server over stdio. It reads the app's own credentials. ────────────────
const transport = new StdioClientTransport({
command: process.env.BROWZ_MCP || 'npx',
args: process.env.BROWZ_MCP ? [] : ['-y', '@browz/mcp'],
env: { ...getDefaultEnvironment(), ...(process.env.BROWZ_DATA ? { BROWZ_DATA: process.env.BROWZ_DATA } : {}) },
stderr: 'inherit',
});
const client = new Client({ name: 'browz-handoff-recipe', version: '1.0.0' });
await client.connect(transport);
async function tool(name, args, options = {}) {
const result = await client.callTool({ name, arguments: args }, undefined, { timeout: 120_000, ...options });
const text = result.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n');
if (result.isError) throw new Error(`${name}: ${text}`);
return text;
}
/** The JSON object at the start of a tool's text (browz_profile_create may add a note after it). */
function firstJson(text) {
const start = text.indexOf('{');
for (let end = text.length; end > start; end = text.lastIndexOf('}', end - 1)) {
try { return JSON.parse(text.slice(start, end + 1)); } catch { /* try a shorter span */ }
}
throw new Error(`no JSON in: ${text.slice(0, 200)}`);
}
const log = (step, detail = '') => console.log(`${step.padEnd(22)} ${detail}`);
let id;
try {
// 1. A profile of its own for this task, started with a window.
id = firstJson(await tool('browz_profile_create', { name: `recipe-handoff-${Date.now()}` })).id;
await tool('browz_profile_start', { id, headless: false });
log('profile started', id);
// 2. Open the page and read it.
await tool('browz_navigate', { id, url: `${BASE}/login` });
const before = await tool('browz_snapshot', { id });
const needsPerson = /NEEDS A PERSON/i.test(before) || /"challenge"/.test(before);
log('snapshot', needsPerson ? 'sign-in page detected: NEEDS A PERSON' : 'no challenge flagged');
// 3. Hand the window to the person. This call waits until they finish (or timeoutSec passes);
// Browz pauses automation on the profile meanwhile, so the agent cannot watch or type.
log('browz_request_human', 'waiting for the person to sign in in the Browz window…');
const outcome = await tool('browz_request_human', {
id,
reason: 'Sign in to the demo shop, then click "I\'m done" in Browz',
kind: 'login',
timeoutSec: 300,
doneWhenUrlContains: '/dashboard',
}, {
timeout: 320_000, // a little longer than timeoutSec
resetTimeoutOnProgress: true,
onprogress: (p) => log(' progress', p.message || `${p.progress}${p.total ? `/${p.total}` : ''}`),
});
log('handoff returned', outcome.split('\n')[0].slice(0, 160));
// 4. Carry on in the same session: take a NEW snapshot (refs from before the handoff are stale).
const after = await tool('browz_snapshot', { id });
const signedIn = after.includes('Welcome back');
log('snapshot after', signedIn ? 'signed in: "Welcome back"' : 'not signed in');
process.exitCode = signedIn ? 0 : 1;
} catch (e) {
// timed_out, cancelled and closed come back as tool errors with a sentence saying what to do.
log('stopped', String(e.message || e));
process.exitCode = 1;
} finally {
if (id) await tool('browz_session_end', { id, delete: true }).catch(() => {});
await client.close();
site.close();
}
Good to know
- Hand-offs use a profile with a window. For a headless profile (and every profile under
browz-mcp serve) the call answers "this profile is running headless, so nobody can see its window". - If nobody finishes within
timeoutSec, the call ends as an error (timed_out); Cancel ends it ascancelled. Ask again or end the session. - Over the hosted server (
mcp.gobrowz.com) one call waits about 70 s and returnsstate: "waiting"with ahandoffId; call again with it to keep waiting. - Your passwords and codes stay in Browz: the agent never sees them. A person completes CAPTCHAs; Browz never solves them. For sign-ins you want automated, store them in Browz and let the agent fill them by reference with
browz_credential_fill; the secret stays in Browz.
Stored sign-in: the agent signs in, the password stays in Browz
local MCP with an agent key, browz_credential_fill · Checked: ran (0.1.9, script and Claude Code)
demo-site.mjsis a small sign-in site that runs on your computer: username and password, then a verification code from an authenticator (TOTP), then a page that says "Signed in as demo-user". Each start makes a fresh random password and TOTP seed and writes them to a file only you can read, ready forbrowz credential add --password-stdin.- You store the sign-in in Browz for that site and one profile, and create a local agent key that may use stored sign-ins on that profile and that site only.
agent.mjsis the agent: a small MCP client (the official@modelcontextprotocol/sdk) that runs the local Browz MCP server with the key. It opens the sign-in page, callsbrowz_credential_list(ids, labels and a masked username), fills the username and password withbrowz_credential_fill, tries to read the password back (the snapshot shows[value=<redacted>]andbrowz_screenshotis refused), signs in, fills the code withbrowz_credential_fillandfield: "totp", and checks the page says "Signed in as demo-user".claude-code/does the same with Claude Code as the agent:.mcp.jsonpasses the key to the Browz MCP server,CLAUDE.mdtells Claude to sign in only with stored sign-ins, andprompt.txtis the task.try-it.shruns every step below in one go, shows what the logs recorded, checks that neither the password nor the seed appears anywhere in what the agent received, and revokes the key. It then removes the demo profile and the stored sign-in (KEEP=1keeps them).
What you need
- The Browz app, installed and open (gobrowz.com/download), and its
browzcommand (or setBROWZto it). - Node.js 20 or later.
- For the Claude Code variant: Claude Code.
Run it
cd stored-sign-in
npm install
./try-it.sh # agent.mjs is the agent
AGENT=claude ./try-it.sh # Claude Code is the agent
stored-sign-in/agent.mjs
// The agent's side: sign in to the demo site with a password and one-time code stored in Browz,
// without ever seeing either. A small MCP client (the official @modelcontextprotocol/sdk) runs the
// local Browz MCP server over stdio with a local agent key, so every call runs as that key: its
// scopes, its one profile and its allowed sites.
//
// BROWZ_LOCAL_AGENT_TOKEN_FILE=./agent-key DEMO_URL=http://127.0.0.1:<port> PROFILE=<id> node agent.mjs
//
// BROWZ_LOCAL_AGENT_TOKEN_FILE file holding the agent key (from `browz agent-token create`)
// DEMO_URL the demo site's address (demo-site.mjs prints it)
// PROFILE the profile the key may use
// BROWZ_MCP the browz-mcp binary (default: npx -y @browz/mcp, which finds the app's)
// SHOW_WINDOW=1 start the profile with a window (default: headless)
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js';
const { DEMO_URL, PROFILE: id, BROWZ_LOCAL_AGENT_TOKEN_FILE: keyFile } = process.env;
if (!DEMO_URL || !id || !keyFile) {
console.error('Set DEMO_URL, PROFILE and BROWZ_LOCAL_AGENT_TOKEN_FILE (see README.md).');
process.exit(2);
}
// The key reaches the MCP server through its environment, as a file name; never on a command line.
const env = { ...getDefaultEnvironment(), BROWZ_LOCAL_AGENT_TOKEN_FILE: keyFile };
if (process.env.BROWZ_DATA) env.BROWZ_DATA = process.env.BROWZ_DATA;
const transport = new StdioClientTransport({
command: process.env.BROWZ_MCP || 'npx',
args: process.env.BROWZ_MCP ? [] : ['-y', '@browz/mcp'],
env,
stderr: 'inherit',
});
const client = new Client({ name: 'browz-stored-sign-in-recipe', version: '1.0.0' });
await client.connect(transport);
/** Call a tool; resolves to { ok, text } so the refusals can be shown too. */
async function call(name, args) {
const result = await client.callTool({ name, arguments: args }, undefined, { timeout: 120_000 });
const text = result.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n');
return { ok: !result.isError, text };
}
async function tool(name, args) {
const r = await call(name, args);
if (!r.ok) throw new Error(`${name}: ${r.text}`);
return r.text;
}
const log = (step, detail = '') => console.log(`${step.padEnd(24)} ${detail}`);
const indent = (text) => text.trim().split('\n').map((l) => ` ${l}`).join('\n');
/** The ref of the first snapshot line with this role and accessible name, e.g. `textbox "Password" [e7]`. */
function refOf(snapshot, role, name) {
const line = snapshot.split('\n').find((l) => l.includes(`${role} "${name}`) && /\[e\d+\]/.test(l));
if (!line) throw new Error(`no ${role} "${name}" in the snapshot:\n${snapshot}`);
return /\[(e\d+)\]/.exec(line)[1];
}
const snap = () => tool('browz_snapshot', { id, mode: 'interactive' });
/** Wait for the next page. While the tab is between two pages its address is empty, which a key
* with allowed sites is not let into, so ask again for a moment until the new page is there. */
async function waitForUrl(part) {
for (let i = 0; ; i++) {
const r = await call('browz_wait', { id, urlContains: part, timeoutMs: 15_000 });
if (r.ok) return;
if (i >= 20 || !/allowed sites/.test(r.text)) throw new Error(`browz_wait: ${r.text}`);
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
let started = false;
try {
// 1. Open the sign-in page in the profile the key may use.
await tool('browz_profile_start', { id, headless: process.env.SHOW_WINDOW !== '1' });
started = true;
await tool('browz_navigate', { id, url: `${DEMO_URL}/login` });
const login = await snap();
const hint = login.split('\n').find((l) => /SAVED SIGN-IN/i.test(l));
log('snapshot', hint ? hint.trim() : 'sign-in page');
// 2. Which stored sign-ins may this profile use here? Ids, labels and masked usernames only.
const listed = await tool('browz_credential_list', { id });
log('browz_credential_list', 'what the agent receives:');
console.log(indent(listed));
const creds = JSON.parse(listed.slice(listed.indexOf('['), listed.lastIndexOf(']') + 1));
const cred = creds.find((c) => c.sites?.some((s) => s.startsWith(DEMO_URL))) || creds[0];
if (!cred) throw new Error('no stored sign-in for this site');
// 3. Browz types the username and the password itself. The agent gets back only "filled".
const filledUser = await tool('browz_credential_fill', { id, credentialId: cred.id, ref: refOf(login, 'textbox', 'Username'), field: 'username' });
log('fill username', filledUser.replace(/\s+/g, ' '));
const filledPass = await tool('browz_credential_fill', { id, credentialId: cred.id, ref: refOf(login, 'textbox', 'Password'), field: 'password' });
log('fill password', filledPass.replace(/\s+/g, ' '));
// 4. The password is in the page now. Try to read it back: snapshots hide it, a screenshot is refused.
const after = await snap();
log('snapshot (password)', after.split('\n').find((l) => l.includes('"Password"'))?.trim());
const shot = await call('browz_screenshot', { id });
log('browz_screenshot', shot.ok ? 'returned an image' : `refused: ${shot.text.split('\n')[0]}`);
// 5. Submit. The next page is a new document, so the read-back guard lifts with it.
await tool('browz_act', { id, action: 'click', ref: refOf(after, 'button', 'Sign in') });
await waitForUrl(cred.hasTotp ? '/verify' : '/account');
// 6. The one-time code: Browz computes it from the stored seed and types it.
if (cred.hasTotp) {
const verify = await snap();
const filledCode = await tool('browz_credential_fill', {
id, credentialId: cred.id, ref: refOf(verify, 'textbox', 'Code from your authenticator app'), field: 'totp', submit: true,
});
log('fill totp', filledCode.replace(/\s+/g, ' '));
await waitForUrl('/account');
}
// 7. Signed in?
const account = await tool('browz_snapshot', { id });
const heading = /Signed in as [\w-]+/.exec(account)?.[0];
log('result', heading || 'not signed in');
process.exitCode = heading ? 0 : 1;
} catch (e) {
log('stopped', String(e.message || e));
process.exitCode = 1;
} finally {
if (started) await call('browz_profile_stop', { id }).catch(() => {});
await client.close();
}
stored-sign-in/demo-site.mjs
// A tiny demo site with a real sign-in: username, password, then a one-time code (TOTP), then a
// "Signed in as …" page. It runs on your computer only (127.0.0.1, a random port), so the recipe
// never signs in to anyone else's site.
//
// node demo-site.mjs # username + password + one-time code
// node demo-site.mjs --no-totp # username + password only
//
// Each start makes a fresh random password and TOTP seed. The site writes them to a file only you
// can read (mode 600), one per line in the order `browz credential add --password-stdin` reads
// them: the password, then the seed. Hand that file to Browz, then delete it (the site also deletes it
// when it stops); the secrets are never printed and never sit in this folder.
import http from 'node:http';
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const USERNAME = 'demo-user';
const WITH_TOTP = !process.argv.includes('--no-totp');
// ── Fresh secrets for this run ─────────────────────────────────────────────────────────────────
const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const base32 = (buf) => {
let bits = '';
for (const b of buf) bits += b.toString(2).padStart(8, '0');
return bits.match(/.{1,5}/g).map((c) => B32[parseInt(c.padEnd(5, '0'), 2)]).join('');
};
const unbase32 = (s) => {
const bits = [...s].map((c) => B32.indexOf(c).toString(2).padStart(5, '0')).join('');
return Buffer.from(bits.match(/.{8}/g).map((b) => parseInt(b, 2)));
};
const password = randomBytes(18).toString('base64url');
const seed = base32(randomBytes(20));
/** RFC 6238: SHA-1, 30-second steps, 6 digits. */
function totp(secret, step) {
const counter = Buffer.alloc(8);
counter.writeBigUInt64BE(BigInt(step));
const mac = createHmac('sha1', unbase32(secret)).update(counter).digest();
const off = mac[mac.length - 1] & 15;
return String((mac.readUInt32BE(off) & 0x7fffffff) % 1_000_000).padStart(6, '0');
}
const codeOk = (code) => {
const now = Math.floor(Date.now() / 30_000);
return [now - 1, now, now + 1].some((s) => totp(seed, s) === code);
};
const same = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
const dir = mkdtempSync(join(tmpdir(), 'browz-demo-signin-'));
const secretsFile = join(dir, 'secrets');
writeFileSync(secretsFile, WITH_TOTP ? `${password}\n${seed}\n` : `${password}\n`, { mode: 0o600 });
// ── The site ───────────────────────────────────────────────────────────────────────────────────
const sessions = new Map(); // cookie value -> 'password' (code still due) | 'ok'
const page = (title, body) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title>
<style>body{font:16px system-ui;max-width:420px;margin:48px auto}label{display:block;margin:12px 0}input{display:block;width:100%;padding:6px}</style>
</head><body>${body}</body></html>`;
const loginForm = (msg = '') => page('Sign in', `<h1>Sign in to the demo site</h1>${msg ? `<p role="alert">${msg}</p>` : ''}
<form method="post" action="/login">
<label>Username <input name="username" type="text" autocomplete="username"></label>
<label>Password <input name="password" type="password" autocomplete="current-password"></label>
<button type="submit">Sign in</button>
</form>`);
const codeForm = (msg = '') => page('Verification code', `<h1>Enter your verification code</h1>${msg ? `<p role="alert">${msg}</p>` : ''}
<form method="post" action="/verify">
<label>Code from your authenticator app <input name="code" type="text" inputmode="numeric" autocomplete="one-time-code"></label>
<button type="submit">Verify</button>
</form>`);
function readForm(req) {
return new Promise((resolve) => {
let body = '';
req.on('data', (c) => { body += c; if (body.length > 4096) req.destroy(); });
req.on('end', () => resolve(new URLSearchParams(body)));
});
}
const send = (res, status, html, headers = {}) => { res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', ...headers }); res.end(html); };
const redirect = (res, to, headers = {}) => { res.writeHead(303, { Location: to, ...headers }); res.end(); };
const server = http.createServer(async (req, res) => {
const sid = /(?:^|;\s*)demo_sid=([A-Za-z0-9_-]+)/.exec(req.headers.cookie || '')?.[1];
const state = sid && sessions.get(sid);
const path = new URL(req.url, 'http://x').pathname;
if (req.method === 'POST' && path === '/login') {
const f = await readForm(req);
if (f.get('username') !== USERNAME || !same(f.get('password') || '', password)) {
return send(res, 401, loginForm('Wrong username or password.'));
}
const id = randomBytes(16).toString('base64url');
sessions.set(id, WITH_TOTP ? 'password' : 'ok');
return redirect(res, WITH_TOTP ? '/verify' : '/account', { 'Set-Cookie': `demo_sid=${id}; Path=/; HttpOnly; SameSite=Lax` });
}
if (path === '/verify' && state === 'password') {
if (req.method !== 'POST') return send(res, 200, codeForm());
const f = await readForm(req);
if (!codeOk((f.get('code') || '').trim())) return send(res, 401, codeForm('That code is not right. Try the current one.'));
sessions.set(sid, 'ok');
return redirect(res, '/account');
}
if (path === '/account') {
if (state === 'ok') return send(res, 200, page('Account', `<h1>Signed in as ${USERNAME}</h1><p>Your demo account is open.</p>`));
return redirect(res, '/login');
}
if (path === '/login' || path === '/') return send(res, 200, loginForm());
send(res, 404, page('Not found', '<h1>Not found</h1>'));
});
server.listen(0, '127.0.0.1', () => {
const url = `http://127.0.0.1:${server.address().port}`;
console.log(`demo site ${url}/login`);
console.log(`username ${USERNAME}`);
console.log(`secrets file ${secretsFile} (${WITH_TOTP ? 'password, then TOTP seed' : 'password'}; mode 600)`);
console.log('Press Ctrl+C to stop.');
});
// The secrets file goes when the site stops (try-it.sh deletes it as soon as Browz has stored it).
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { rmSync(dir, { recursive: true, force: true }); server.close(); process.exit(0); });
stored-sign-in/claude-code/.mcp.json
{
"mcpServers": {
"browz": {
"command": "npx",
"args": ["-y", "@browz/mcp"],
"env": { "BROWZ_LOCAL_AGENT_TOKEN_FILE": "${BROWZ_AGENT_KEY_FILE}" }
}
}
}
stored-sign-in/claude-code/CLAUDE.md
## Signing in with Browz
Use the Browz MCP tools (`browz_*`) for every web page in this project.
- Read a page with `browz_snapshot` and act with `browz_act` on the refs it returns (`e12`). Take a new
snapshot after every navigation.
- To sign in, use a sign-in the person stored in Browz. A snapshot of a sign-in page names it
(`SAVED SIGN-IN: …`); `browz_credential_list` with the profile `id` lists every one this profile may
use. Fill each field with `browz_credential_fill`: its `credentialId`, the field's ref, and `field`
`username`, `password` or `totp` (for a verification code). Pass `submit: true` on the last field of
a form. Browz types the value and tells you only that it was filled.
- Never type, guess or ask for a password or code yourself. If no stored sign-in fits the page, call
`browz_request_human` with a short reason and wait.
- While a filled password is in a page, screenshots are refused and snapshots show it as
`[value=<redacted>]`. Submitting the form loads the next page and lifts that.
Good to know
- A stored sign-in works only where you said: on the sites you list with
--site(over https, orhttp://on your own computer as here), on the profiles its scope names (--profile,--groupor--any), and for a key, also inside the key's own profiles and allowed sites. - Browz types a password or code only into a password field, and a code also into a field marked
autocomplete="one-time-code", as the demo's code field is. Browz computes each code at the moment of the fill from the stored seed. - While a filled secret is in a page, snapshots show
[value=<redacted>], and screenshots, script and text waits on that profile are refused. Loading the next page (submitting the form) lifts this. - Each fill is one row in the local action log (
credential.fill: credential, field, origin, outcome, key) and one in the key's own log, always without the value. Each credential allows 12 fills in any 10 minutes. browz_profile_startneedsbrowz:profiles:write; with--profiles <id>the key reaches that one profile only. Revoke the key when the job is done, or give it a short life with--days.- For a page with no stored sign-in, or a CAPTCHA, the agent hands the window to you with
browz_request_human(human-handoff). A person completes CAPTCHAs; Browz never solves them. - Store sign-ins only for sites whose terms allow automated access, and only for your own accounts.
A scheduled headless run on a server
browz-mcp serve, cron/systemd/launchd · Checked: ran
browz-mcp serve(part of Browz) starts the Browz agent with headless profiles and serves MCP over Streamable HTTP athttp://127.0.0.1:50334/mcp, behind a bearer token.run.mjsconnects with the official MCP client, and for each URL on its command line:browz_session_open(an ephemeral session: a new profile with its own fingerprint and storage),browz_navigate,browz_snapshot,browz_screenshot(saved toout/), thenbrowz_session_close, which wipes the profile. It writesout/report.jsonand exits non-zero if any page failed.schedule/has the same job for cron, a systemd timer and a macOS LaunchAgent.
What you need
- The Browz binaries on the server: on macOS the app bundle (
/Applications/Browz.app/Contents/Resources/browz-mcp; the app need not be open); on Linux x64 the agent and engine package or the Docker image (see the headless guide, gobrowz.com/docs/ai-agents). Run it as an ordinary user, never root. - Node.js 20 or later for
run.mjs. - About 0.25 GB of memory per open session, plus 1 GB.
Run it
# 1. A bearer token, readable by you only.
umask 077; openssl rand -hex 32 > ~/.browz-mcp-token
# 2. The headless agent (keep it running: a systemd service, a LaunchAgent, or Docker).
BROWZ_MCP_TOKEN_FILE=~/.browz-mcp-token /Applications/Browz.app/Contents/Resources/browz-mcp serve
# Linux: BROWZ_MCP_TOKEN_FILE=… BROWZ_CHROMIUM_BINARY=/opt/browz-engine/…/chrome /opt/browz/bin/browz-mcp serve
# 3. The check.
cd headless-scheduled
npm install
BROWZ_MCP_TOKEN_FILE=~/.browz-mcp-token node run.mjs https://example.com/ https://example.org/
# 4. Schedule it: schedule/crontab.txt, schedule/browz-check.{service,timer}, or the LaunchAgent plist.
headless-scheduled/run.mjs
// A scheduled, headless check: for each URL, open an isolated throwaway Browz session through the
// MCP server that `browz-mcp serve` runs, read the page, save a screenshot, and wipe the session.
//
// npm install
// BROWZ_MCP_TOKEN_FILE=~/.browz-mcp-token node run.mjs https://example.com/ https://example.org/
//
// Run it from cron, a systemd timer or launchd (files next to this one). Exits non-zero when any
// page fails, so the scheduler (or your alerting) sees it.
//
// BROWZ_MCP_URL MCP endpoint (default http://127.0.0.1:50334/mcp, what `serve` prints)
// BROWZ_MCP_TOKEN_FILE file holding the bearer token (or BROWZ_MCP_TOKEN with the token itself)
// BROWZ_PROXY none (default) | pool | a proxy id from browz_proxy_list
// OUT_DIR where screenshots and report.json go (default ./out)
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
const MCP_URL = process.env.BROWZ_MCP_URL || 'http://127.0.0.1:50334/mcp';
const TOKEN = (process.env.BROWZ_MCP_TOKEN
|| (process.env.BROWZ_MCP_TOKEN_FILE ? await readFile(process.env.BROWZ_MCP_TOKEN_FILE, 'utf8') : '')).trim();
if (!TOKEN) throw new Error('set BROWZ_MCP_TOKEN_FILE (or BROWZ_MCP_TOKEN) to the bearer token `serve` uses');
const PROXY = !process.env.BROWZ_PROXY || process.env.BROWZ_PROXY === 'none' ? 'none'
: process.env.BROWZ_PROXY === 'pool' ? 'pool' : { proxyId: process.env.BROWZ_PROXY };
const OUT_DIR = process.env.OUT_DIR || 'out';
const URLS = process.argv.slice(2).length ? process.argv.slice(2) : ['https://example.com/'];
const client = new Client({ name: 'browz-scheduled-check', version: '1.0.0' });
await client.connect(new StreamableHTTPClientTransport(new URL(MCP_URL), {
requestInit: { headers: { Authorization: `Bearer ${TOKEN}` } },
}));
/** Call a Browz tool; a tool error becomes an exception carrying Browz's own message. */
async function tool(name, args) {
const result = await client.callTool({ name, arguments: args }, undefined, { timeout: 120_000 });
const text = result.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n');
if (result.isError) throw new Error(`${name}: ${text}`);
return { result, text };
}
/** The JSON object at the start of a tool's text (some tools add a note after it). */
function firstJson(text) {
const start = text.indexOf('{');
for (let end = text.length; end > start; end = text.lastIndexOf('}', end - 1)) {
try { return JSON.parse(text.slice(start, end + 1)); } catch { /* shorter */ }
}
throw new Error(`no JSON in: ${text.slice(0, 200)}`);
}
await mkdir(OUT_DIR, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const report = { startedAt: new Date().toISOString(), mcp: MCP_URL, pages: [] };
for (const [i, url] of URLS.entries()) {
const entry = { url, ok: false };
let sessionId;
try {
// A fresh, isolated profile for this check; wiped by browz_session_close.
const open = firstJson((await tool('browz_session_open', { task: `scheduled check of ${url}`, mode: 'ephemeral', proxy: PROXY, ttlMinutes: 30 })).text);
sessionId = open.sessionId;
const id = open.profileId; // the page tools take the profile id
await tool('browz_navigate', { id, url });
const snap = (await tool('browz_snapshot', { id, mode: 'compact', maxTokens: 1500 })).text;
entry.title = /^Title: (.+)$/m.exec(snap)?.[1] ?? null; // the snapshot starts with URL: and Title: lines
entry.snapshotStart = snap.split('\n').slice(0, 6);
const shot = (await tool('browz_screenshot', { id })).result.content.find((c) => c.type === 'image');
if (shot) {
entry.screenshot = join(OUT_DIR, `${stamp}-${i + 1}.png`);
await writeFile(entry.screenshot, Buffer.from(shot.data, 'base64'));
}
entry.ok = true;
} catch (e) {
entry.error = String(e.message || e);
} finally {
if (sessionId) {
const closed = await tool('browz_session_close', { sessionId }).catch((e) => ({ text: String(e.message) }));
entry.cleanup = closed.text.includes('"cleaned"') ? 'cleaned' : closed.text.slice(0, 200);
}
}
report.pages.push(entry);
}
await client.close();
report.ok = report.pages.every((p) => p.ok);
await writeFile(join(OUT_DIR, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify(report, null, 2));
process.exitCode = report.ok ? 0 : 1;
Good to know
- For a task that needs a sign-in, use a persistent identity signed in beforehand, or a stored sign-in (
browz_credential_fill). Hand-offs to a person (browz_request_human) use a profile with a window, as in human-handoff. servelistens on loopback. To reach it from other machines, put a TLS reverse proxy in front and pass--allowed-host <name>; never serve plain HTTP beyond the machine.- Not signed in to a Browz account, the local agent runs any number of sessions, unmetered. Signed in, your plan sets how many sessions run at once (2 on Free, 5 on Pro 20, and more on larger plans).
- Give scheduled jobs their own local agent key (
browz-mcp agent-token create --profiles own …) instead of the server's token. - Use it only on sites whose terms allow automated access. A person completes CAPTCHAs; Browz never solves them.
Reference: the MCP tool list, the Local API OpenAPI spec and llms.txt. Background: engine-level vs injected-script fingerprint protection and browsers for AI agents, compared.