Configuration¶
There are no command-line flags and no config file. Everything is set with environment variables, read once at startup. This page lists every one the code reads, file by file.
proxy.py (what the installer runs)¶
| Variable | Default | What it does |
|---|---|---|
BROKER_PROXY_PORT |
9222 |
Port the CDP front door listens on. This is what your tools dial. |
BROKER_UPSTREAM_PORT |
9229 |
Port your real browser's remote debugging listens on. |
BROKER_PORT |
9223 |
Port for the REST lease API and the stage. |
BROKER_TTL |
300 |
Default lease TTL in seconds for POST /lease and POST /renew when the body has no ttl. |
BROKER_HEARTBEAT |
3600 |
Seconds between heartbeat lines in the log. |
BROKER_LOG |
broker.log next to proxy.py |
Log file path. ~ is expanded. tabs.json is written in the same directory. |
BROKER_STAGE_DWELL |
20 |
Seconds a page shown through the stage is protected from the next /show. |
BROKER_STAGE_WAIT |
30 |
Seconds a /show caller queues before getting 409. |
BROWSER_APP |
Brave Browser |
macOS app name the stage activates with osascript after showing a page. |
BROKER_DEBUG |
unset | Any non-empty value turns on Python DEBUG logging, which includes the websockets handshake trace. When unset, websockets.server handshake errors are silenced. |
All ports bind to 127.0.0.1 only.
Fixed values in proxy.py¶
These are constants in the code, not settings:
| Constant | Value | Meaning |
|---|---|---|
UNATTACHED_TTL |
60 s | A tab made for a legacy /json client that never attaches is closed after this. |
POOL_HOLD |
10 s | A tab listed by GET /json is reserved for that caller for this long. |
| Reaper interval | 5 s | How often expired leases and unattached tabs are swept. |
| Upstream reply timeout | 20 s | How long the proxy waits for the browser to answer one command. |
| REST call timeout | 30 s | How long a REST request waits on the proxy's event loop before 504. /show uses wait + 15. |
LOG_MAX |
5 MB | broker.log is renamed to broker.log.1 past this. |
STDIO_MAX |
10 MB | If stdout or stderr is a plain file (launchd / Task Scheduler) past this, it is emptied. |
FOCUS_STEALERS |
Target.activateTarget, Page.bringToFront |
Answered with success, never forwarded. |
BROWSER_KILLERS |
Browser.close, Browser.crash, Browser.crashGpuProcess, Browser.setWindowBounds |
Answered with success, never forwarded. |
broker_client.py¶
| Variable | Default | What it does |
|---|---|---|
BROKER_URL |
http://127.0.0.1:9223 |
Where the REST API is. |
BROKER_OWNER |
unset | Owner name show() uses when you don't pass owner. Falls back to anonymous. |
Fixed: renew every 60 s, default ttl 300 s. _post uses a 15 s HTTP timeout.
Tab connects with a 30 s socket timeout.
bb-show¶
| Variable | Default | What it does |
|---|---|---|
BROKER_URL |
http://127.0.0.1:9223 |
REST API base. |
BROKER_OWNER |
unset | Owner when the second argument is missing. |
CLAUDE_SESSION_ID |
unset | If BROKER_OWNER is unset, the owner is basename of this; else agent. |
BROWSER_APP |
Brave Browser |
App for the fallback open -a when the broker does not answer. |
The request to /show has a 60 second curl timeout. Any status other than 200
or 409 (including no answer at all) falls back to open -a, which only works on macOS.
broker.py (legacy, not run by the installer)¶
| Variable | Default | What it does |
|---|---|---|
BROKER_CDP_PORT |
9222 |
The browser's CDP port. broker.py talks to the browser directly, with no proxy in front. |
BROKER_PORT |
9223 |
REST API port. |
BROKER_TTL |
300 |
Default lease TTL. |
BROKER_HEARTBEAT |
3600 |
Seconds between heartbeat lines. |
BROKER_LOG |
broker.log next to broker.py |
Log file. |
broker.py serves /lease, /adopt, /renew, /release, /status, /health.
It has no stage and no CDP filtering: a lease's ws_url points straight at the
browser.
Install and launch scripts¶
| Script | Variable | Default | What it does |
|---|---|---|---|
get.sh |
BROWSER_BROKER_DIR |
~/.browser-broker |
Where the repo is cloned. |
get.sh, install.sh |
BROKER_PORT |
9223 |
Port used for the "already installed?" and post-install health checks. |
install.sh |
BROKER_PYTHON |
unset | Python to try first. |
launch-browser.sh |
BROKER_UPSTREAM_PORT |
9229 |
Port passed to --remote-debugging-port. |
launch-browser.sh |
BROWSER_APP |
Brave Browser |
App to quit and relaunch, e.g. Google Chrome. |
windows\install.ps1 |
BROKER_TASK |
browser-broker |
Scheduled task name. |
windows\install.ps1 |
BROKER_PYTHON |
unset | Python to use. |
windows\launch-browser.ps1 |
BROKER_UPSTREAM_PORT |
9229 |
Remote debugging port. |
windows\launch-browser.ps1 |
BROWSER_EXE |
first found of Brave, then Chrome | Browser executable. |
windows\run-broker.ps1 |
BROKER_PYTHONW |
contents of windows\pythonw.txt, else pythonw.exe |
Interpreter for proxy.py. |
Settings the services do not pass on
The macOS LaunchAgent plist sets no environment variables, so proxy.py run by
launchd always uses the defaults (9222 / 9229 / 9223). Setting BROKER_PORT
when running install.sh only changes which port the script checks afterwards.
On Windows, run-broker.ps1 checks port 9229 and install.ps1 checks port 9223
with hardcoded values. TODO-VERIFY: whether non-default ports are meant to be
supported under the installed services.
Browser flags used by both launchers: --remote-debugging-port=<port>,
--remote-allow-origins=*, --restore-last-session,
--disable-backgrounding-occluded-windows, --disable-renderer-backgrounding,
--disable-background-timer-throttling.
REST API (port 9223)¶
All bodies are JSON. Paths are matched by prefix.
| Endpoint | Body | Reply |
|---|---|---|
POST /lease |
{owner, url, purpose, ttl, hidden} |
{lease_id, target_id, ws_url, expires_in} |
POST /renew |
{lease_id, ttl} |
{lease_id, expires_in}, or 404 {"error": "unknown lease"} |
POST /release |
{lease_id} |
{released}, or 404 |
POST /show |
{owner, url, purpose, wait, dwell} |
{shown, target_id, held_for, owner}; 409 {error, held_by, seconds_left, showing} if busy; 400 without url |
POST /unstage |
none | {freed: true/false} |
POST /adopt |
any | Always 410 on the proxy |
GET /status |
{tabs: [...], count, brave_alive, leases: [...]} |
|
GET /health |
{ok: true, brave: <browser answers>} |
|
GET /stage |
{held_by, showing, purpose, seconds_left, tab, tabs} |
Defaults: owner is anonymous, url is about:blank, ttl is BROKER_TTL.
A request that waits too long on the browser gets 504 {"error": "browser did not
answer in time"}; any other exception gets 500.
Notes on what the proxy code actually does:
POST /releaseon the proxy always closes the tab. It ignoresclose. The README's "closes the tab unlessclose: false" describesbroker.py. TODO-VERIFY: whetherclose: falseshould be honoured by the proxy.hiddenis stored and shown in/status, but every tab is opened the same way (background: true). It changes nothing else inproxy.py./statuslists each lease'spurposeasnull, even though/leasestores it. TODO-VERIFY: likely an oversight./renewresets the expiry from now; it does not add to the remaining time./unstagefrees whoever holds the stage, not only the caller.
CDP endpoints (port 9222)¶
HTTP:
| Path | Behaviour |
|---|---|
/json/version |
The browser's own version info, with (browser-broker) appended to Browser and a fresh webSocketDebuggerUrl on the proxy port. A trailing slash is accepted. |
/json, /json/list |
One unclaimed tab for this caller, reserved for 10 s. Made fresh if none is free. |
/json/new?<url> |
GET or PUT. Opens a new pool tab at <url> (default about:blank). |
/json/activate/<id> |
200 Target activated (noop). Nothing is forwarded. |
/json/close/<id> |
Closes the tab only if it is a pool tab (one made for a legacy /json client); 404 otherwise. REST and browser-client tabs can't be closed this way. |
/json/protocol |
Passed through from the browser. |
Websockets:
/devtools/browser/<anything>: a new browser-level client with its own owner id./devtools/page/<id>: only for tabs the proxy made. Unknown ids are closed with1008 not your tab; a pool tab that already has a socket gets1008 tab already in use.
Browser-level command handling:
| Command | What happens |
|---|---|
Target.getTargets |
Only this client's tabs. If it has none, one about:blank tab is made for it. |
Target.createTarget |
Made by the proxy as a background tab owned by this client. With auto-attach on, the reply waits (up to about 2 s) until the attach event has been sent. |
Target.attachToTarget, Target.closeTarget, Target.detachFromTarget, Browser.getWindowForTarget |
Error No target with given id found for any tab that isn't this client's. |
Target.attachToBrowserTarget |
Error browser target is not shared. |
Target.createBrowserContext / disposeBrowserContext |
Allowed; a client can only dispose contexts it created. Its contexts are disposed when it disconnects. |
Target.setDiscoverTargets, Target.setAutoAttach |
Forwarded; the resulting events are filtered to this client's targets. Other tabs that auto-attach are detached quietly. |
| Focus and browser-wide commands | See FOCUS_STEALERS and BROWSER_KILLERS above. Browser.close also closes this client's connection. |
On a page socket, only the focus and browser-wide commands are intercepted; everything else goes straight to the tab.
Claude Code hook example¶
hooks-example-focus-guard.py is a PreToolUse hook for Claude Code's Bash tool.
It denies commands matching:
open -a <browser>(Brave, Google Chrome, Chromium, Safari),- AppleScript that tells one of those browsers to
activate, set the active tab, make a new tab or set a window index, - raw
/json/activate/,Target.activateTargetorPage.bringToFront,
and tells the agent to use bb-show instead. Commands that already mention
bb-show or the /show endpoint on 9223 are let through. Each decision is
appended to ~/.claude/browser-focus-audit.log.
Its shebang (#!/opt/homebrew/bin/python3.12) and its message text come from the
author's machine; adjust them before use. TODO-VERIFY: the exact settings.json
snippet for registering it; the repo does not include one.