Make your browser searchable like a file system — only semantically.
bts finds running browser windows and tabs across Chrome, Edge, and Firefox, lets you pick one by
describing it in plain language, and brings it to the foreground. It ships as a standalone CLI and an MCP
server, so Claude Code, GitHub Copilot, Cursor, and Windsurf all share the same browser context.
$ bts find "the CRM with customer Contoso" --activate
Unique match for "the CRM with customer Contoso":
Microsoft Edge - Work - Dynamics 365 | Contoso
https://contoso.crm4.dynamics.com/main.aspx
Activated: Microsoft Edge - Work
Window brought to the foreground.Status: working on Windows 11, 145 unit tests, CDP and BiDi paths verified against real browsers. See honest limitations.
When you ask an AI agent for a screenshot, a Playwright recording, or a visual check, it has to know which browser window you mean. Today you read a window title off your screen and retype it into the chat. With several Chrome profiles and a dozen open windows, that is slow and error-prone.
bts inventories what is actually running and offers a stable, numbered choice — or resolves your intent
directly.
- All logic lives in the CLI. Skills, agents, and MCP tools are thin adapters with no detection heuristics.
- One neutral JSON protocol for every host.
- Capability-driven setup: detect automatically, ask only about real gaps.
- Never disguise missing precision. If tabs cannot be determined, say so — never invent them.
- Never change a browser you did not start. No added flags, no restarts, no navigation, no silently
enabled debug ports. (
bts launchstarts a separate instance with its own profile — it never modifies or reuses your personal one.) - Privacy first. URLs are redacted by default; private windows are excluded unless asked for.
- Choose by number. Users never retype titles, profiles, or technical IDs.
Several tools give AI agents access to browser tabs. They solve different problems — pick by what you actually need.
| bts | tabb | OpenTabs | |
|---|---|---|---|
| Goal | pick and focus the right target | manage tabs from terminal/agent | act inside web apps |
| Browser access | none required — process + window enumeration; CDP/BiDi only via a debug port | Chrome extension + native messaging | Chrome extension |
| Extension needed | no | yes | yes |
| Platform | Windows | macOS, Linux | Chrome, any MCP client |
| Browsers | Chrome, Edge, Firefox | Chromium family | Chrome |
| Page control | none by design | close, reload, read content | clicking, typing, screenshots, DOM, network, ~2000 tools via plugins |
| Reads page content | no | yes (markdown) | yes, plus internal APIs of the logged-in app |
What bts deliberately does not do: it does not read page content, does not click, type, or scrape, and
does not call an application's APIs. If you want an agent to operate Slack, Jira, or GitHub through your
logged-in session, OpenTabs is the far more capable tool and solves a problem bts does not attempt. If
you are on macOS or Linux and want terminal tab management, look at tabb.
Where bts is different:
- No extension, no page access. It works with what is observable from outside a running browser: windows, profiles, and the active tab title. Nothing is injected into your pages.
- Full access only when you ask for it. For recordings and automation,
bts launchstarts a separate instance with a debug port and its own profile. Your everyday browser is never restarted, never reconfigured, and never given a debug port behind your back. - Honest degradation. When it cannot see background tabs, it says so via the capability level instead of guessing.
- Selection, not control. The job is answering "which window/tab do you mean?" and bringing it forward — then handing off to Playwright, a screenshot tool, or an agent.
The three are complementary rather than mutually exclusive: bts can pick and focus the target, and a
content-level tool can take it from there.
Requirements: Windows 11, .NET SDK 8 or newer.
git clone <repository-url>
cd browser-target-selector
.\setup.ps1 -Hosts both -AddToPathThis builds bts.exe and bts-mcp.exe into %LOCALAPPDATA%\BrowserTargetSelector\bin, adds that directory
to your user PATH, installs the host adapters, and runs a smoke test. Open a new terminal afterwards so
PATH is picked up, then verify:
bts doctorUninstall with .\uninstall.ps1 -Hosts both.
-Hosts(not-Host):$Hostis a reserved PowerShell variable.
dotnet build Bts.slnx
dotnet test tests/Bts.Tests/Bts.Tests.csproj
dotnet run --project src/Bts.Cli -- inventorybts find "unity tutorial video"
bts find "my mail" --activate # activate straight away on a unique match
bts find "contoso" --activate # several matches -> asks for a number, then activatesSearch runs offline: weighted matching over host, title, path, profile, and browser, plus curated
synonyms (CRM → Dynamics/Salesforce, mail → Outlook/Gmail) and diacritics folding. Nothing leaves your
machine. A unique match is reported directly; ambiguous results become a numbered prompt.
What is searchable by default: for a browser you started normally,
btssees every window and its active tab title — but not background tabs and no URLs. Windows exposes no API for that. To search and activate all tabs, the browser has to run with a debug port; see Full tab lists and URLs. The capability level says which case you are in, so nothing is ever invented.
bts inventory
bts activate 2 # activates window [2] from the list above[1] Google Chrome - Default → win_chrome_1
▶ Power Apps
[2] Microsoft Edge - Work → win_edge_1
▶ Dynamics 365 | Contoso
Activate: bts activate <number> e.g. bts activate 2
Or by context: bts find "<context>" --activate
Snapshot: snap_ab12cd34 - valid until 14:05:12 UTCIf the snapshot has expired, bts activate <number> simply re-inventories and tells you it did.
bts select --scope window --interactive # numbered terminal menu
bts doctor # installed browsers + capability report
bts groups # tabs bundled by site, for large tab sets
bts dedupe # tabs pointing at the same page (read-only)
bts export --out session.json # portable, redacted state snapshot
bts config show
bts config set redaction strict
bts inventory --json # exactly one JSON document on stdoutdedupe reports duplicates — it never closes a tab. The same page in a different profile is not a
duplicate, since "the CRM in Work" and "the CRM in Personal" are different targets. find uses the same
identity rule to collapse duplicates, so three tabs on one page stay a single, unambiguous choice:
$ bts dedupe
1 duplicate tab(s) in 1 group(s), out of 6 tabs:
2x Example Domain
Google Chrome - Default https://example.com/
win_chrome_2_tab_1 (active)
win_chrome_2_tab_3
Duplicates are only reported - bts never closes a tab.groups bundles tabs by registrable domain, so make.powerapps.com and admin.powerapps.com land in one
group. With dozens of tabs open, an agent should show the groups first and only then the tabs inside the
chosen one — a flat list of 40 options is unusable. Tabs whose URL is unknown are bucketed per
browser/profile and marked as such, never mixed into a domain group:
$ bts groups
8 tab(s) in 4 group(s):
[1] Google Chrome - Default (3 tab(s)) (URL unknown)
* Some page -> win_chrome_2_active
[2] example.com (2 tab(s))
* Example Domain -> win_chrome_1_tab_1
Example Domain -> win_chrome_1_tab_5
Activate a tab: bts activate <tabTargetId>export writes windows, profiles, and redacted tab URLs as JSON. It deliberately omits window handles,
process IDs, and snapshot IDs — they are meaningless elsewhere and would leak process details into a file
people tend to share.
Every command supports --json. Structured output goes to stdout, diagnostics to stderr.
The [1] [2] … in bts inventory are a display counter, not the target ID. The real identifier
(win_chrome_1) appears greyed out at the end of each line.
You normally never need them — bts activate <number>, bts find … --activate, and
bts select --interactive all resolve IDs internally. When you do need them, they come from the output of
bts find --json, bts inventory --json, or bts select --json, and are valid only within that snapshot
(30 s by default, configurable via bts config set snapshot-ttl).
Activation is deliberately conservative: the existing window (and, with full capability, the tab) comes to
the foreground. No navigation, no reload, no restart. That is exactly what you need before a tool acts on
"the active window":
bts find "Power Apps" --activate # focus the right window
# ... then run your screenshot / Playwright / automation tool
bts find "Power Apps" --json # or hand snapshotId + targetId to another agentbts is a target selector, not a Playwright replacement. It reliably determines which target is meant.
By default bts sees windows and the active tab title (window_and_active_tab). To search and activate
all tabs including redacted URLs (full), the browser must run with a loopback debug port. bts never
enables one itself — that is your deliberate decision. Close the browser fully, then:
& "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222
& "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --remote-debugging-port=9222
& "C:\Program Files\Mozilla Firefox\firefox.exe" --remote-debugging-port=9222 # WebDriver BiDibts detects the port from the process command line and enriches exactly those windows. Nothing else about
the invocation changes.
bts inventory --include-tabs --jsonPick a tab from a numbered list, or activate one directly:
# numbered tab list for one window
bts select --scope tab --window win_chrome_1 --snapshot-id snap_ab12cd34
# activate a BACKGROUND tab (brings the window forward AND switches to that tab)
bts activate win_chrome_1_tab_2 --snapshot-id snap_ab12cd34
# -> Window brought to the foreground. Tab activated via CDP.
# or just describe it - background tab content is searchable now
bts find "iana help example domains" --activate
bts activate <number>always means a window. For a specific tab, pass its target ID (win_chrome_1_tab_2) or usebts find … --activate.
Tab activation uses CDP for Chrome/Edge and WebDriver BiDi for Firefox — it switches the tab without navigating or reloading it.
Security note: the port is bound to
127.0.0.1, but while it is open any local application can use it. Enable it only when you need it, and restart the browser normally afterwards.
If you are going to record or drive a browser anyway (Playwright, screenshots, E2E work), you do not need
to restart your personal browser at all. bts launch starts a controlled, separate instance with a debug
port and its own profile:
bts launch --browser chrome --url https://example.comStarted Google Chrome (controlled instance)
PID 2696 - debug endpoint http://127.0.0.1:36057
Dedicated profile: C:\Users\you\AppData\Local\BrowserTargetSelector\profiles\chrome
Your personal browser profile is untouched.
Debug endpoint is ready - bts now has full tab access for this instance.
Next: bts inventory --include-tabsFrom that moment the instance has full capability — all tabs, all URLs, tab activation:
bts inventory --include-tabs
bts find "the app under test" --activateOptions: --port (default: a free port is picked), --profile-dir (default: a managed directory per
browser, so logins survive between runs), --fresh (throwaway profile), --url (repeatable), --json.
Safety properties, enforced in code and covered by tests:
- always a dedicated profile directory — your personal profile is never reused, so no profile locks and no session bleed
- the instance is started detached: it survives a short-lived agent process, and it never blocks a caller
that pipes
btsoutput - already running browsers are left completely untouched
That covers the automation use case completely — no browser extension required. An extension would only help in the other case: reading all tabs of a browser you already have open and do not want to restart. It remains deliberately unbuilt — see ADR 0004.
| Browser | Without debug port | With --remote-debugging-port |
|---|---|---|
| Chrome / Edge | windows + active tab title | all tabs + redacted URLs (CDP) |
| Firefox | windows + active tab title, profile from profiles.ini |
all tabs + redacted URLs (WebDriver BiDi) |
bts-mcp.exe speaks JSON-RPC over stdio and exposes the same logic as tools:
| Tool | Purpose |
|---|---|
browser_find |
describe a context → scored matches + confident flag |
browser_inventory |
snapshot of all windows/tabs |
browser_select |
numbered selection / resolve a number |
browser_activate |
bring a window/tab to the foreground |
browser_launch |
start a controlled instance with full tab access (see above) |
browser_groups |
bundle tabs by site so large tab sets stay selectable |
browser_dedupe |
report tabs pointing at the same page (read-only) |
browser_export |
portable, redacted snapshot of the browser state |
Same configuration shape everywhere, only the file location differs:
{
"mcpServers": {
"bts": {
"command": "C:\\Users\\<you>\\AppData\\Local\\BrowserTargetSelector\\bin\\bts-mcp.exe",
"args": []
}
}
}- Claude Code:
claude mcp add bts -- "<path>\bts-mcp.exe", or an entry in.mcp.json - Cursor:
.cursor/mcp.json(project) or~/.cursor/mcp.json(global) - Windsurf:
~/.codeium/windsurf/mcp_config.json - GitHub Copilot (VS Code):
.vscode/mcp.json
Then just ask: "Find the tab with the Contoso CRM and bring it to the front."
setup.ps1 (or bts install-host --host claude --scope user) writes
~/.claude/skills/browser-target-selector/SKILL.md. Allow the CLI once in ~/.claude/settings.json:
{
"permissions": {
"allow": ["Bash(bts *)", "Bash(bts.exe *)"]
}
}The skill activates automatically when your request matches, or explicitly via
/browser-target-selector.
bts install-host --host copilot --scope projectWrites .github/copilot/agents/browser-target-selector.agent.md. Add a short pointer to
.github/copilot-instructions.md telling Copilot to use bts for browser targets.
English is the default. German is fully translated:
bts inventory --lang de
$env:BTS_LANG = "de" # per session
bts config set language de # persistentResolution order: --lang > BTS_LANG > configuration file > English. A unit test enforces that the German
table covers every English key.
- URL redaction (default
balanced): query and fragment removed.strictkeeps only the domain. Even atoff, known secret parameters (token,code,password, …) stay masked, andfile:/data:/blob:URLs are always masked. - Private/incognito windows are excluded unless you pass
--include-private. - Snapshots are short-lived and contain only redacted data.
- No telemetry, no network calls except to
127.0.0.1debug ports you opened yourself. - No admin rights required.
Details and the honestly documented detection gap: docs/SECURITY_PRIVACY.md.
bts always reports what it can actually deliver:
| Level | Meaning |
|---|---|
full |
all windows, tabs, titles and redacted URLs |
window_and_active_tab |
windows plus the active tab |
window_only |
windows reliable, tabs unknown |
process_only |
only the process/installation detected |
unavailable |
adapter not usable |
- Without a debug port only the active tab title is available — background tabs and URLs are not.
about:pages in Firefox return no title via BiDi (privileged); shown as(untitled).- A Chromium incognito window showing a regular page cannot be detected passively (same process, no title
marker). Documented in
docs/SECURITY_PRIVACY.md. - Windows may refuse foreground activation in rare cases;
btsreports this instead of claiming success. - Windows only for now. Other platforms are an adapter concern, not a rewrite.
src/
├─ Bts.Core/ domain model, wire contracts, localization, exit codes, configuration
├─ Bts.Adapters/ IBrowserAdapter, Windows baseline (process + user32),
│ CDP enricher (Chromium), BiDi enricher (Firefox), native interop
├─ Bts.Application/ discovery, capability, inventory, selection, activation, redaction,
│ semantic search, snapshot store
├─ Bts.Cli/ System.CommandLine front end, host installer, setup wizard
└─ Bts.Mcp/ host-neutral MCP server (stdio JSON-RPC)
tests/Bts.Tests/ xUnit (145 tests)
docs/ architecture, CLI spec, security, ADRs, implementation status
schemas/ JSON Schema (Draft 2020-12) for the wire protocol
docs/IMPLEMENTATION_STATUS.md— what actually works, verified howdocs/ARCHITECTURE.md·docs/CLI_SPEC.md·docs/SECURITY_PRIVACY.mddocs/adr/— decisions on browser discovery, CDP, Firefox, the optional extension, privacy, and the semantic search / MCP direction
See CONTRIBUTING.md. The design rules above are non-negotiable; please add an ADR for
architectural changes.
MIT © Andreas Brunsmann (@brunsforge)
{ "snapshotId": "snap_ab12cd34", "browsers": [{ "displayName": "Google Chrome", "windows": [{ "targetId": "win_chrome_1", "capability": "full", // <- all tabs are known "tabs": [ { "targetId": "win_chrome_1_tab_1", "title": "Example Domain", "url": "https://example.com/", "isActive": true }, { "targetId": "win_chrome_1_tab_2", "title": "Example Domains", "url": "https://www.iana.org/help/example-domains", "isActive": false }, { "targetId": "win_chrome_1_tab_3", "title": "Example Domain", "url": "https://example.org/", "isActive": false } ]}]}] }