Every verb, every flag, with output captured by task docs:capture against the fixture in internal/docfixture (four profiles, two of them held open the way a running browser holds them). That is why the BROWSER column and browser field read custom here: the fixture is passed with --root, and roots given explicitly are labelled custom. On a real machine they read chrome, chrome-beta, chrome-canary, chrome-dev, chromium, brave, edge, arc, vivaldi, opera, or opera-gx.
Contents: Every command at a glance · Global flags · Shared flags · list · list --profiles · Claude accounts · find · skills · completion · Exit codes · JSON shape · Table columns
| Command | What it does | Exits |
|---|---|---|
browserctrl list |
one row per profile with the Claude extension: device id, running/idle, MCP flag, browser, profile, name, email, display name, Claude account | 0 |
browserctrl list --profiles |
the same plus profiles with no extension, marked not installed — see list --profiles |
0 |
browserctrl find <term>... |
the device id of the single profile matching every term | 0 hit · 1 none · 2 ambiguous |
browserctrl skills <list, get, path, check, version, refresh> |
the agent skill this binary ships — see skills | 0 · 1 stale/error |
browserctrl completion <bash, zsh, fish, powershell> |
shell completion script — see completion | 0 |
browserctrl --version |
version, commit and dirty marker — see Global flags | 0 |
browserctrl help [command] |
cobra's built-in help | 0 |
| Flag | On | Effect |
|---|---|---|
--json |
list, find |
JSON instead of the table: an array for list, one object for find — see JSON shape |
--running |
list, find |
only profiles open right now; unknown is excluded deliberately |
--reachable |
list, find |
only browsers on the Claude account this session runs as |
--all |
list, find |
also scan the two Claude desktop-app extension ids |
--root <dir> |
list, find |
scan this user-data dir instead of the well-known ones; repeatable |
--profiles |
list |
include profiles without the extension |
--account-alias <uuid>=<label> |
list |
name a Claude account in the CLAUDE ACCOUNT column; repeatable |
$ browserctrl --help
List Claude-connected Chromium browser profiles and their device ids
Usage:
browserctrl [command]
Available Commands:
completion Generate the autocompletion script for the specified shell
find Print the device id of the single profile matching all terms
help Help about any command
list Show every profile with the Claude extension, running ones first
skills List and retrieve this tool's bundled agent skills
Flags:
-h, --help help for browserctrl
-v, --version version for browserctrl
Use "browserctrl [command] --help" for more information about a command.
--version comes from ubgo/buildinfo and has three shapes depending on how the binary was produced. A go build from a checkout (what task build does) embeds Go's pseudo-version, the short commit, and +dirty when the tree had uncommitted changes:
$ browserctrl --version
browserctrl version v0.0.0-20260912061725-90d9f421ae81+dirty (90d9f42)
A volt release build is stamped with the tag (browserctrl version v0.1.0 (90d9f42)), and go install …@vX.Y.Z reports that module version. go run embeds no VCS data and prints browserctrl version dev.
list and find take the same five flags, so a query behaves identically whichever verb runs it. list takes two more of its own, --profiles and --account-alias.
| Flag | Effect |
|---|---|
--json |
Emit JSON instead of the table. list prints an array of entries, find prints the single matched entry. |
--running |
Keep only profiles whose state is running. unknown is excluded on purpose (see Table columns). |
--reachable |
Keep only browsers signed in to the Claude account this session runs as: the config CLAUDE_CONFIG_DIR selects, else ~/.claude.json. These are the only ids select_browser accepts. Unlike the CLAUDE ACCOUNT labels it is deliberately session-specific, so the same command answers differently from a work profile and a personal one. If the session's account cannot be determined it exits 1 rather than print an empty list; if no browser is on that account it says so by name. See Claude accounts. |
--all |
Also scan the two extension ids allowed by the Claude desktop app's native-host manifest, not just the Claude Code extension. Entries carry the extension field so they can be told apart. |
--root <dir> |
Scan this Chromium user-data directory instead of the well-known install locations. Repeatable. Entries are labelled browser: custom. Use it for --user-data-dir profiles such as Chrome for Testing or a Playwright persistent context. |
--profiles (list only) |
Also list profiles that have no Claude extension installed, so a missing profile is visible instead of silently absent. See list --profiles. Not on find: such a profile has no device id to resolve. |
--account-alias <uuid>=<label> (list only) |
Give a Claude account uuid a readable name in the CLAUDE ACCOUNT column. Repeatable. See Claude accounts for why only one account can be named without it. A pair without = is an error, not a silently ignored argument. |
Show every profile that has the Claude extension installed, running ones first, then by browser kind, then by profile directory. A profile where the extension is not installed has no device id anywhere on disk, so it is omitted entirely — pass --profiles to see those too.
$ browserctrl list --help
Lists one row per profile that has a Claude extension store on disk, which
is the only place a device id exists. A profile where the extension is not
installed has no id, cannot be selected by the MCP, and is not listed unless
you pass --profiles.
Usage:
browserctrl list [flags]
Examples:
browserctrl list
browserctrl list --running --json
browserctrl list --profiles
browserctrl list --account-alias 2f7c1b90-...=work@example.com
Flags:
--account-alias stringArray name a Claude account: <account-uuid>=<label> (repeatable)
--all scan the Claude desktop-app extension ids too, not just Claude Code's
-h, --help help for list
--json emit JSON instead of a table
--profiles also list profiles that do NOT have the Claude extension installed
--reachable only browsers signed in to the Claude account this session runs as
--root stringArray scan this Chromium user-data dir instead of the well-known ones (repeatable)
--running only profiles currently open in a running browser
$ browserctrl list
STATE MCP DEVICE ID BROWSER PROFILE NAME EMAIL DISPLAY NAME CLAUDE ACCOUNT
running yes ce9a8e06-61d3-4e7b-894b-13fd92987212 custom Default Aman aman@example.com chrome-main aman@example.com
running yes 2aa533d3-d03f-4dd8-b664-f59b8930eccc custom Profile 5 Work aman@work.example work-chrome aman@example.com
idle yes f836694e-b2f0-4e5b-93e4-ff946c6183ad custom Profile 23 legable aman@legable.co aman-legable other account
idle no - custom Profile 4 Fresh fresh@example.com - -
The last row is a profile where the extension is installed but has never connected: no device id yet, MCP no. It is listed so you can see the extension is there; find will never pick it.
$ browserctrl list --running
STATE MCP DEVICE ID BROWSER PROFILE NAME EMAIL DISPLAY NAME CLAUDE ACCOUNT
running yes ce9a8e06-61d3-4e7b-894b-13fd92987212 custom Default Aman aman@example.com chrome-main aman@example.com
running yes 2aa533d3-d03f-4dd8-b664-f59b8930eccc custom Profile 5 Work aman@work.example work-chrome aman@example.com
$ browserctrl list --running --json
[
{
"deviceId": "ce9a8e06-61d3-4e7b-894b-13fd92987212",
"displayName": "chrome-main",
"state": "running",
"mcpConnected": true,
"browser": "custom",
"browserPath": ".docs-fixture",
"profileDir": "Default",
"profileName": "Aman",
"email": "aman@example.com",
"accountUuid": "11111111-1111-4111-8111-111111111111",
"orgUuid": "1111aaaa-1111-4111-8111-111111111111",
"extension": "fcoeoabgfenejglbffodgkkbkcdhcgfn",
"installed": true
},
{
"deviceId": "2aa533d3-d03f-4dd8-b664-f59b8930eccc",
"displayName": "work-chrome",
"state": "running",
"mcpConnected": true,
"browser": "custom",
"browserPath": ".docs-fixture",
"profileDir": "Profile 5",
"profileName": "Work",
"email": "aman@work.example",
"accountUuid": "11111111-1111-4111-8111-111111111111",
"orgUuid": "1111aaaa-1111-4111-8111-111111111111",
"extension": "fcoeoabgfenejglbffodgkkbkcdhcgfn",
"installed": true
}
]
When nothing matches (no extension anywhere, or --running on a machine with every browser closed) the table form prints one hint line and exits 0:
no Claude extension stores found (is the extension installed in any profile?)
The JSON form prints null in that case (an empty Go slice), so scripts should treat null and [] alike.
Answers "why is my profile not in the list?". Chrome installs extensions per profile, and the device id is created by the Claude extension on its first run in that profile. A profile the extension was never installed in therefore has no id, is invisible to list_connected_browsers and select_browser, and — without this flag — is not printed at all, whether or not it is open right now.
--profiles adds one row per such profile and an EXTENSION column saying which is which:
$ browserctrl list --profiles
STATE EXTENSION MCP DEVICE ID BROWSER PROFILE NAME EMAIL DISPLAY NAME CLAUDE ACCOUNT
running installed yes ce9a8e06-61d3-4e7b-894b-13fd92987212 custom Default Aman aman@example.com chrome-main aman@example.com
running not installed no - custom Profile 26 Personal personal@example.com - -
running installed yes 2aa533d3-d03f-4dd8-b664-f59b8930eccc custom Profile 5 Work aman@work.example work-chrome aman@example.com
idle installed yes f836694e-b2f0-4e5b-93e4-ff946c6183ad custom Profile 23 legable aman@legable.co aman-legable other account
idle installed no - custom Profile 4 Fresh fresh@example.com - -
Profile 26 is the case to recognise: open right now, a real signed-in profile, and completely unusable from Claude Code until the extension is installed in it. Note the two different empty-id rows — Profile 26 has no extension, Profile 4 has one that has never connected — which is why the JSON carries an explicit installed field rather than leaving you to infer it from an empty deviceId.
The STATE of a row without the extension is read from that profile's own localStorage LevelDB lock instead of the extension store's, so running/idle is just as real. A profile directory Chromium has never opened has neither lock and reports unknown.
--profiles composes with the other flags: --running --profiles shows only open profiles including the ones missing the extension, and --json adds the same rows to the array.
The CLAUDE ACCOUNT column answers a question the device id cannot: which browsers this session can reach at all. list_connected_browsers only reports browsers signed in to the same Claude account as the session asking, so a browser on another account is invisible to that session no matter how open it is, and select_browser rejects its id with "No connected browser has deviceId …".
It reads in plain words rather than uuids:
| Cell | Meaning |
|---|---|
| an email address | a Claude account that one of your Claude Code profiles is signed in as; a session running as that account can reach these browsers |
other account (numbered 1, 2, … when there are several) |
a Claude account no profile on this machine is signed in as, so it cannot be named |
| a label you chose | an account you named with --account-alias |
- |
the extension is installed but signed out (or, under --profiles, not installed at all) |
$ browserctrl list --account-alias 22222222-2222-4222-8222-222222222222=work@example.com
STATE MCP DEVICE ID BROWSER PROFILE NAME EMAIL DISPLAY NAME CLAUDE ACCOUNT
running yes ce9a8e06-61d3-4e7b-894b-13fd92987212 custom Default Aman aman@example.com chrome-main aman@example.com
running yes 2aa533d3-d03f-4dd8-b664-f59b8930eccc custom Profile 5 Work aman@work.example work-chrome aman@example.com
idle yes f836694e-b2f0-4e5b-93e4-ff946c6183ad custom Profile 23 legable aman@legable.co aman-legable work@example.com
idle no - custom Profile 4 Fresh fresh@example.com - -
Why only one account gets an email. Claude stores no email in the browser — only an account uuid. That was checked against the extension's own storage, claude.ai's site data inside the profile (__qk_hint_account_uuid, ccd-sync-owner and its analytics payloads, which carry account_uuid and organization_uuid and nothing else), Claude Code's config and every backup of it, and past session transcripts. The one place a uuid is paired with an email is a Claude Code config, for the account that profile is signed in as, which is where the email cells come from. browserctrl reads every one it can find: the config CLAUDE_CONFIG_DIR points at, ~/.claude.json, and each sibling profile directory matching ~/.claude* (~/.claude-work/.claude.json and so on). If you keep several Claude Code profiles, every one of their accounts is named, and the answer does not depend on which shell you ask from — a plain terminal and a Claude Code session print the same table. A profile directory kept somewhere else is not discovered; name its account with --account-alias. Naming any other account is therefore something only you can do, with --account-alias <uuid>=<label>; take the uuid from --json (accountUuid) and put the flag in a shell alias.
Labels are computed over the whole scan, not the rows being printed, so --running and --profiles never rename an account.
The EMAIL column is a different thing. That is the Google/Microsoft account of the browser profile, from Local State. The two are unrelated: a profile signed in to Chrome as one person can be signed in to Claude as another, which is exactly the shape that makes a mismatch hard to spot.
--reachable applies that rule for you: it keeps the browsers whose account is the one this session is signed in as.
$ browserctrl list --reachable
STATE MCP DEVICE ID BROWSER PROFILE NAME EMAIL DISPLAY NAME CLAUDE ACCOUNT
running yes ce9a8e06-61d3-4e7b-894b-13fd92987212 custom Default Aman aman@example.com chrome-main aman@example.com
running yes 2aa533d3-d03f-4dd8-b664-f59b8930eccc custom Profile 5 Work aman@work.example work-chrome aman@example.com
In the fixture the session runs as aman@example.com, so Profile 23 (another account) and Profile 4 (signed out) drop out. From a session on a different Claude Code profile the same command lists a different set, and when none qualifies it prints one line naming the session's account instead of the generic "is the extension installed?" hint.
So a device id is usable when three things hold, and browserctrl can verify only the first two from disk: the extension is installed in that profile, the account matches the session's, and the extension's bridge is live at that moment. The third is knowable only from list_connected_browsers.
Resolve free text to exactly one device id and print it, nothing else, so it can be captured: id=$(browserctrl find legable).
$ browserctrl find --help
Every term must match (case-insensitive substring) one of: display name,
profile name, email, profile dir, browser kind, device id. When several
profiles match but exactly one is running, that one wins. Otherwise the
candidates are listed on stderr and the exit code is 2.
Usage:
browserctrl find <term>... [flags]
Examples:
browserctrl find legable
browserctrl find vivaldi work
DEVICE=$(browserctrl find analyzify)
Flags:
--all scan the Claude desktop-app extension ids too, not just Claude Code's
-h, --help help for find
--json emit JSON instead of a table
--reachable only browsers signed in to the Claude account this session runs as
--root stringArray scan this Chromium user-data dir instead of the well-known ones (repeatable)
--running only profiles currently open in a running browser
Matching rules, in order:
- The query is split on whitespace. Every term must appear, case-insensitively, somewhere in the entry's display name, profile name, email, profile directory, browser kind, or device id. Terms may hit different fields (
work chrome→ display namework-chrome, or profile nameWork+ browserchrome). - Entries with no device id are dropped: an empty id is useless to
select_browser. - One entry left → print its id, exit 0.
- Several left, exactly one of them
running→ print that one, exit 0. The window that is open is almost always the one meant. - Otherwise exit 2 and list the candidates on stderr.
$ browserctrl find legable
f836694e-b2f0-4e5b-93e4-ff946c6183ad
$ browserctrl find work
2aa533d3-d03f-4dd8-b664-f59b8930eccc
Rule 4 in action: example.com matches the Default profile (email aman@example.com) and Profile 4 (fresh@example.com), but Profile 4 has no device id, so the match is unique:
$ browserctrl find example.com
ce9a8e06-61d3-4e7b-894b-13fd92987212
Ambiguous (both chrome-main and work-chrome contain chrome and both are running), exit 2:
$ browserctrl find chrome
error: query matches more than one browser
STATE MCP DEVICE ID BROWSER PROFILE NAME EMAIL DISPLAY NAME CLAUDE ACCOUNT
running yes ce9a8e06-61d3-4e7b-894b-13fd92987212 custom Default Aman aman@example.com chrome-main aman@example.com
running yes 2aa533d3-d03f-4dd8-b664-f59b8930eccc custom Profile 5 Work aman@work.example work-chrome aman@example.com
Everything above goes to stderr; stdout stays empty so $(…) captures nothing rather than garbage. Add a term to disambiguate: browserctrl find chrome main.
No match, exit 1:
$ browserctrl find firefox
error: no browser matches the query
With --json the whole entry is printed instead of the bare id:
$ browserctrl find legable --json
{
"deviceId": "f836694e-b2f0-4e5b-93e4-ff946c6183ad",
"displayName": "aman-legable",
"state": "idle",
"mcpConnected": true,
"browser": "custom",
"browserPath": ".docs-fixture",
"profileDir": "Profile 23",
"profileName": "legable",
"email": "aman@legable.co",
"accountUuid": "22222222-2222-4222-8222-222222222222",
"orgUuid": "2222bbbb-2222-4222-8222-222222222222",
"extension": "fcoeoabgfenejglbffodgkkbkcdhcgfn",
"installed": true
}
Note the entry is idle: find does not require a running browser unless you pass --running. Pass it when the id is going straight into select_browser, which can only connect to an open window.
Serves the agent skill(s) this binary ships, always at the version that matches the binary. Provided by voltkit/skillcmd; the wiring is skills_gen.go at the repo root, generated by volt gen skills.
$ browserctrl skills --help
Serves browserctrl's agent skills (SKILL.md format), always matched to this
binary's version (dev): content is fetched once per version from the
project's release and cached; a binary upgrade re-syncs automatically.
Install into an agent harness with skills.sh: npx skills add khanakia/browserctrl
Verify an installed copy: browserctrl skills check <dir>
Usage:
browserctrl skills [flags]
browserctrl skills [command]
Available Commands:
check Is an installed copy of a skill current for this binary?
get Print a skill's full content
list List available skills
path Print the filesystem path of the skills (or one skill)
refresh Re-download this version's skills bundle (publish-recovery; never automatic)
version Binary version, canonical skills hash, and the serving source
Flags:
-h, --help help for skills
Use "browserctrl skills [command] --help" for more information about a command.
Where the content comes from, first hit wins: the BROWSERCTRL_SKILLS_DIR environment variable; for a dev build (version dev, which includes any go build from a checkout) the nearest skills/ directory walking up from the working directory; otherwise the per-version cache under os.UserCacheDir()/browserctrl/skills/<version>/, filled once from the <version> release's skills_<version>.tar.gz and checksum-verified. Every command says which source served.
$ browserctrl skills
browserctrl-core Pick the right Chrome/Chromium browser for the claude-in-chrome MCP without a click-through prompt.
install for agents: npx skills add khanakia/browserctrl
$ browserctrl skills version
browserctrl dev
skills_hash: d07cbd067bc84cf303b290d082aafc6f897399488956f5b47f3e3ef3b6bc1879
source: live (/path/to/checkout/skills)
$ browserctrl skills check skills/browserctrl-core
browserctrl-core current
skills check <dir> exits 0 when the installed copy matches this binary's skill byte for byte (hidden files such as .DS_Store ignored) and 1 when it is stale — the freshness loop the SKILL.md itself tells an agent to run. skills get browserctrl-core prints the full SKILL.md (--json, --full for supporting files); skills path prints the directory being served; skills refresh drops and re-fetches the cache for a release build. The source: path above is shortened; it is the absolute path of the checkout's skills/ directory.
Standard cobra shell completion. browserctrl completion bash|zsh|fish|powershell prints the script; browserctrl completion --help shows the per-shell install lines.
| Code | Meaning | Emitted by |
|---|---|---|
0 |
success (including an empty list) |
all verbs |
1 |
failure: scan error, unreadable Local State, usage error, or find with no match |
all verbs |
2 |
find matched more than one entry with a device id and could not tie-break on running; candidates are on stderr |
find |
1 and 2 are distinct so a script can decide to ask the user on 2 rather than treat it as a hard failure.
Both verbs emit the same object; list wraps it in an array, find prints one. Every field is always present except error, which appears only when that store could not be read.
| Field | Type | Meaning |
|---|---|---|
deviceId |
string | bridgeDeviceId from the extension store — the value select_browser takes. Empty when the extension has never connected. |
displayName |
string | bridgeDisplayName — the name typed in the extension's "name this browser" prompt. Empty if never set. |
state |
"running" | "idle" | "unknown" |
Whether the profile is open right now, from the LevelDB LOCK. unknown on Windows or when the lock file could not be opened. |
mcpConnected |
bool | The extension has completed an MCP handshake at some point. Sticky: it is not cleared when the browser exits, so combine with state for "connected now". |
browser |
string | Browser family (chrome, edge, vivaldi, …) or custom for --root dirs. |
browserPath |
string | The user-data root that was scanned. |
profileDir |
string | Profile directory name inside the root (Default, Profile 5). The stable key. |
profileName |
string | Profile label from Local State. Empty for browsers that do not populate it (Opera). |
email |
string | Signed-in account from Local State, empty if signed out. |
accountUuid |
string | The Claude account the extension is signed in as (accountUuid in its storage). Empty when signed out or not installed. Decides which sessions can reach this browser — see Claude accounts. |
orgUuid |
string | The organization of the current token (tokenOrg.uuid). Empty when signed out. Read from tokenOrg, not lastActiveOrgHint: the hint goes stale after an account switch and the two are known to disagree on a real profile. |
extension |
string | Which extension id this store belongs to. Only differs from the Claude Code id under --all. Empty when installed is false. |
installed |
bool | Whether a Claude extension store exists for this profile. Always true except for the extra rows list --profiles adds. False means the profile can never yield a device id until the extension is installed in it — distinct from an installed extension that has simply never connected (installed: true with an empty deviceId). |
error |
string, optional | Why this store could not be read. deviceId and displayName are empty when set. |
STATE · MCP (yes/no = mcpConnected) · DEVICE ID · BROWSER · PROFILE (directory) · NAME · EMAIL · DISPLAY NAME · CLAUDE ACCOUNT. Empty cells show - so columns stay aligned and a missing value cannot be mistaken for a rendering glitch. A row whose store failed to read gets an extra trailing cell ERROR: <reason>. list --profiles inserts one more column after STATE: EXTENSION, either installed or not installed.
Sort order is running → idle → unknown, then browser kind alphabetically, then profile directory. Running entries come first because they are the ones an agent can connect to.