Skip to main content
Glama
code-x-coffee

universal-browser-bridge

Universal Agent Browser Bridge

A local, agent-agnostic bridge that lets MCP-compatible AI agents operate Chrome tabs you explicitly share or create under their agent label.

It works with Hermes, Claude Code, Codex, and other MCP clients while keeping your signed-in Chrome session, tab access, and consequential-action approvals under your control.

This is an independent project inspired by visible tab-consent browser-control systems. It is not affiliated with OpenClaw, Anthropic, Google, OpenAI, or Nous Research.

What it provides

  • A Chrome Manifest V3 extension using chrome.debugger

  • Explicit per-tab access through a visible Agent Bridge tab group and ON badge

  • One local serve daemon that owns the Chrome connection

  • Multiple simultaneous MCP clients connected through authenticated local IPC

  • Navigation, snapshots, clicking, typing, key presses, scrolling, screenshots, and tab management

  • Generation-scoped DOM references that become invalid after navigation

  • Chrome-hosted human approval for consequential actions, with configurable trust for agent-owned tabs

  • A user-editable site blocklist enforced by both the extension and daemon

  • Persistent label ownership of agent-created tabs in named Chrome tab groups

  • Per-tab serialization so multiple agents cannot interleave browser actions

Related MCP server: Tabryn

Architecture

Chrome extension
      |
      | authenticated WebSocket on 127.0.0.1:17321
      v
Universal Browser Bridge daemon (`serve`)
      |
      | authenticated Unix socket / Windows named pipe
      +---- MCP adapter (`mcp`) for Hermes
      +---- MCP adapter (`mcp`) for Claude Code
      +---- MCP adapter (`mcp`) for Codex
      +---- MCP adapter (`mcp`) for another session or agent

The Chrome extension maintains one connection to the daemon. Start the daemon once, then connect as many MCP adapter processes as you need.

Quick start

1. Clone and build

Requirements:

  • Google Chrome or a compatible Chromium browser with Manifest V3 and chrome.debugger

  • Node.js and npm (the project is verified with Node.js 22)

  • An MCP-compatible agent or client

git clone git@github.com:code-x-coffee/universal-browser-bridge.git
cd universal-browser-bridge
npm ci
npm run build

If you cloned over HTTPS instead:

git clone https://github.com/code-x-coffee/universal-browser-bridge.git

2. Generate the pairing token

npm run token

Copy the printed token. It is stored at:

~/.universal-browser-bridge/token

Keep it private. The extension and every local MCP adapter use it to authenticate with the daemon.

3. Load the Chrome extension

  1. Open chrome://extensions.

  2. Enable Developer mode.

  3. Click Load unpacked.

  4. Select the repository's extension/ directory.

  5. Open the extension's Details page.

  6. Click Extension options.

  7. Set Relay URL to:

    ws://127.0.0.1:17321/extension
  8. Paste the pairing token.

  9. Click Save and connect.

The extension can be configured before the daemon starts. Its toolbar badge clears once the daemon is running and authentication succeeds.

Loading the extension from a different directory creates a different unpacked-extension identity in Chrome. You must enter the relay URL and token again for that new identity.

4. Start the daemon

Run this in a dedicated terminal and leave it running:

npm run serve

Equivalent direct command:

node /absolute/path/to/universal-browser-bridge/dist/cli.js serve

Expected startup output resembles:

Universal Browser Bridge daemon listening.
  Extension endpoint: 127.0.0.1:17321
  Control socket:     ~/.universal-browser-bridge/daemon.sock
  Token:               ~/.universal-browser-bridge/token

Ctrl-C gracefully stops the daemon, disconnects all MCP adapters, and removes the control socket.

5. Configure your MCP client

The MCP command is:

node /absolute/path/to/universal-browser-bridge/dist/cli.js mcp

Use an absolute path because MCP clients often launch subprocesses from a different working directory.

Standard MCP configuration:

{
  "mcpServers": {
    "browser-bridge": {
      "command": "node",
      "args": [
        "/absolute/path/to/universal-browser-bridge/dist/cli.js",
        "mcp"
      ],
      "env": {
        "UBB_CLIENT_LABEL": "my-agent"
      }
    }
  }
}

Use a different UBB_CLIENT_LABEL for each responsibility, for example research, marketing, or coding. The label appears in Chrome approval prompts and ownership errors.

Hermes Agent

Add this to ~/.hermes/config.yaml:

mcp_servers:
  browser-bridge:
    command: node
    args:
      - /absolute/path/to/universal-browser-bridge/dist/cli.js
      - mcp
    env:
      UBB_CLIENT_LABEL: hermes
    enabled: true

Start a new Hermes session after changing MCP configuration. The discovered tools are prefixed by Hermes with the configured MCP server name.

Multiple sessions and agents

Each MCP configuration launches a lightweight adapter. All adapters connect to the same daemon, so multiple agent sessions can use the bridge simultaneously without competing for port 17321.

Do not configure every adapter to run serve. Only one daemon should run. Every MCP client should run mcp.

6. Share a tab

  1. Open a normal http:// or https:// page.

  2. Click the extension toolbar icon.

  3. Confirm the tab shows an ON badge.

  4. Confirm Chrome placed it in the Agent Bridge tab group.

  5. Ask your agent to call browser_status, browser_tabs, or browser_snapshot.

  6. Click the toolbar icon again whenever you want to revoke access.

Chrome internal pages, extension pages, password-manager prompts, passkeys, native dialogs, and arbitrary desktop applications are intentionally outside the bridge's scope.

Agent-owned tabs

Set UBB_CLIENT_LABEL for an MCP adapter, or pass --label to the CLI. A labelled browser_new_tab creates a tab in the dedicated agent window and groups it under the label's exact title. It reuses a matching group in that window. By default, the agent window is created without a prompt and without taking focus. Chrome asks first only if you turn off Let agents open their window without asking in Extension options.

The package provides both ubb and universal-browser-bridge CLI names. From a checkout, use node dist/cli.js in place of ubb:

ubb --label Hephaestus browser_new_tab '{"url":"https://example.com"}'
ubb --label Hephaestus browser_list_agent_tabs '{}'
# Use the id from browser_new_tab, or tabId from browser_list_agent_tabs:
ubb --label Hephaestus browser_close_tab '{"tabId":123}'

browser_list_agent_tabs returns {tabId, url, title, groupId} for each tab owned by the caller's label. browser_status and browser_tabs include hand-shared tabs and the caller's owned tabs. Every browser tool accepts owned tab IDs. Other labels, including clients without a label, cannot list, read, drive or close those tabs. A label is a caller-supplied identity: authenticated clients presenting the same label share ownership, even across separate CLI invocations or MCP sessions.

Current tab access is stored in chrome.storage.session; labelled ownership is also stored in chrome.storage.local under agentTabOwnersDurable. It survives service-worker restarts, extension reloads/updates, and daemon/client reconnects when the recorded tab, exact label/group title and agent window still validate. The group title is the literal label, including spaces and pattern characters; unlabelled tabs use Agent Bridge. Restoration never derives a label from a group or adopts other tabs in that group. Missing tabs, invalid mappings, duplicate records and ambiguous windows are dropped. The agent window is restored only from validated owned tabs. Hand-shared and unlabelled access remains session-only.

Closing, ungrouping, moving a tab to another group/window, renaming its group, clicking the toolbar to unshare, or cancelling debugging revokes ownership and removes its durable record. Durable revocation also prevents stale session data from restoring access. Merely placing an existing tab in a named group never grants an agent access to it.

Without a label, browser_new_tab keeps the existing behavior: it creates a shared, unowned tab in Agent Bridge. All clients may read and drive it. While its creating client remains connected, only that client may close it; after disconnect it may be closed by another client. browser_list_agent_tabs requires a label. browser_close_tab also accepts hand-shared tabs legitimately controlled by the caller. Hand sharing still uses Agent Bridge and the ON badge.

Tab creation requests are serialized. If a requesting client disconnects while its request is queued, the daemon drops the request. If it disconnects while Chrome is creating the tab, the daemon attempts to close the orphan before processing the next request, without recording ownership. Cleanup is best effort; if Chrome refuses the close, the tab may remain open.

Trust and site safety settings

Open Extension options to change Trust agent-owned tabs, Let agents open their window without asking, or Blocked sites, then click Save safety settings. These settings are stored in chrome.storage.local and apply immediately without restarting the daemon.

Trust agent-owned tabs defaults to enabled (trustAgentOwnedTabs: true when unset). Consequential clicks and Enter/NumpadEnter skip the approval popup only when the extension confirms that the target tab is currently owned by the calling label. Turn it off to require approval in owned tabs too. Hand-shared and unlabelled tabs always retain the approval flow for these actions. Clients cannot override the setting with a request flag. Labels remain caller-supplied identities, so authenticated clients using the same label share that trust.

Let agents open their window without asking defaults to enabled (autoCreateAgentWindow: true when unset). When no live agent window exists, the extension creates one with focused: false without an approval popup. Turn it off to require approval before creating the window; opening more tabs in an existing agent window does not prompt. The extension reads the setting for each window creation. With this setting on, any local process holding the bridge token can open agent tabs in your signed-in Chrome without a prompt.

Bank, card, payment and password-manager URLs, including the initial URL of a newly created tab, allow snapshots, screenshots and scrolling, but block clicks, typing, key presses and navigation even with trust enabled. The shared URL policy recognizes listed financial domains (including HDFC, ICICI, SBI, Axis, Kotak, PayPal, Stripe, Razorpay, American Express, Discover, Capital One, Visa and Mastercard), password managers (1Password, Bitwarden, LastPass, Dashlane, Keeper, Google Password Manager, NordPass and Proton Pass), common banking/payment hostname labels, and /checkout, /payment, /payments or /pay path segments. It checks current and pending URLs before execution, including after approval waits. Proton's /pass pages and pass.proton.me are protected without making its other pages read-only. This is conservative URL classification, not an exhaustive sensitive-site directory. The private-network opt-in does not disable it.

Blocked sites defaults to empty (blockedDomains: []). Enter one domain per line; pasted URLs are accepted. Saving lowercases domains, strips schemes, paths, ports, leading www. and trailing dots, converts international names to their ASCII form, and removes empty lines and duplicates. Invalid entries show an error without replacing the saved settings. For example, https://www.example.com/private becomes example.com and blocks that domain and every subdomain, but does not match notexample.com or example.com.other.org. To allow a site again, remove its entry and save.

Blocked pages are hidden from tab listings and refuse new tabs, navigation to the domain, snapshots, screenshots, clicks, typing, keys, scrolling and every CDP command. This applies to both owned and hand-shared tabs, regardless of trust. Both the live URL and pendingUrl are checked. The extension sends its normalized blocklist over the authenticated connection on connect and on changes; the daemon checks its copy before dispatch and rechecks live tab state through the extension. The extension independently checks again before executing. Refusals contain "blocked by your blocklist". The blocklist controls bridge access to tabs; it does not filter a page's network requests or stop you from opening sites yourself. Previously saved annotations remain available in the local annotation store.

Uploading files

Use browser_upload_file to attach local files through an existing file input or an upload button that opens a file chooser. Pass ref, description, snapshotId and files (an array of absolute file paths). tabId is optional and otherwise uses the selected tab. Take a fresh browser_snapshot after navigation. Optional mode controls how the files are attached:

  • auto (default): use an <input type="file">, an associated label, or a container with exactly one file input when available; otherwise click the ref and intercept the chooser.

  • input: require a resolvable file input and retain the "Target is not a file input" error for other elements.

  • chooser: always click the ref and intercept the chooser, including dynamically created inputs.

{
  "ref": "ubb-3",
  "description": "Attach the reference image",
  "snapshotId": "7:0",
  "files": ["/Volumes/NVme/Projects/Stories/reference.png"],
  "mode": "auto",
  "tabId": 7
}

In Extension options, set Allowed upload folders, one absolute folder per line, then click Save safety settings. The default when unset is /Volumes/NVme/Projects/Stories/. An empty list disables uploads. Invalid and relative entries are ignored; saved paths are normalized and duplicates removed. Only extension settings can change this list. An older extension that does not send the setting leaves uploads disabled in the daemon.

Uploads require a labelled agent and a tab owned by that same label. Hand-shared tabs, blocklisted sites, and bank/card/payment/password-manager pages cannot receive files. Disabled file inputs are rejected; attaching multiple files requires the input's multiple attribute. Each call accepts 1–10 regular files, each at most 100 MB (104,857,600 bytes).

The daemon resolves files and allowed folders with realpath and checks containment, so sibling prefixes and symlinks escaping an allowed folder are rejected. Symlinks pointing into an allowed folder resolve to the real file path, which is passed to Chrome and returned as { "uploaded": 1, "files": ["/absolute/real/path"], "method": "input", "picker": "input" }. method is input or chooser; picker is input or showOpenFilePicker. The extension independently checks these real paths against its configured folder prefixes; when configuring a symlinked folder, also allow its real destination. Paths containing NUL or .. segments are refused.

Chooser mode uses the same scroll-to-center, hit-test and trusted mouse input as browser_click, with a user-activated script fallback for covered, zero-size or offscreen targets. Consequential clicks retain approval. The extension handles interception and attachment in one command, listens only for the target tab's chooser, and rejects multiple files if it opens in single-selection mode. If no chooser opens within 5 seconds, the error reports whether the showOpenFilePicker shim was triggered, with a file-scheme permission hint when that permission is off.

During a chooser call, window.showOpenFilePicker is temporarily replaced with a hidden file input that respects multiple and the file types in accept. Its promise resolves after the input's change event to file-handle-like objects with kind, name, getFile, permission methods and isSameEntry. These are not native FileSystemFileHandle instances; pages checking instanceof FileSystemFileHandle may fail. The extension always attempts to disable interception and restore the original picker after success, timeout or error. A 10-second page timer also restores the shim if normal cleanup cannot run.

Open chrome://extensions, find Universal Browser Bridge, open Details, and enable Allow access to file URLs. Reload the updated extension and rebuild/restart the daemon and MCP adapters before using the new tool. Live Chrome uploads are a separate manual check from automated tests.

Downloading files

Use browser_download to save media from a tab owned by your agent label directly to an allowed local folder. Blob/data downloads use the existing page-held Blob path. Same-origin HTTP(S) downloads also use that path first, with manual redirect handling. Only an opaqueredirect response releases the page entry and retries through Chrome debugger response interception and IO streams, which validate every hop. Other page-fetch results and failures keep their existing behavior. Trusted cross-origin HTTPS downloads go directly through interception, keeping cookies inside Chrome while bypassing page CORS body restrictions. A redirecting same-origin URL is requested twice: once to detect the redirect, then again to inspect and validate the chain. Non-redirecting same-origin downloads avoid interception. Chrome's Save As preference and the background agent window do not affect saving. Pass filename, description, and either ref + snapshotId from browser_snapshot or url. Snapshots include visible video, image and audio elements, plus source children of visible media, within the existing 250-element cap. Ref sources can be <video>, <audio>, <source>, <img> or <a>; the tool uses currentSrc, then src, then the first <source> child for video/audio without their own source, or href for links. Video/audio/source nodes include only the source hostname as srcHost (or blob: / data:), never its path or signed query.

{
  "ref": "ubb-3",
  "snapshotId": "7:0",
  "filename": "scene.mp4",
  "description": "Save the generated scene",
  "subfolder": "renders",
  "tabId": 7
}

For a URL source, replace ref and snapshotId with url. HTTP(S) URLs can have the same origin as the tab; cross-origin sources require HTTPS and a matching exact hostname or explicit leading *. wildcard under Trusted download hosts in Extension options. Blob URLs must belong to the tab origin; data URLs are also allowed. Other schemes and cross-origin plain HTTP are refused. Every redirect hop must use HTTPS and be same-origin or trusted, with a maximum of 10 redirects. Both the extension and daemon check every hop before Chrome sends the next request. Blocklisted hosts are always refused, including redirect destinations. Downloads require a labelled agent, a tab owned by that label, and a writable, unblocked site. Hand-shared tabs cannot download files. Ref downloads require a current snapshot after navigation. The tool reads media without clicking the page.

In Extension options, edit Allowed download folders and click Save safety settings. This list is separate from upload folders and defaults to /Volumes/NVme/Projects/Stories/ only when the setting is unset. An empty list disables downloads; an older extension that does not send the policy also leaves downloads disabled. folder optionally selects a configured folder; the default is the first. subfolder is relative to it. Neither agents nor tools can edit the allowed list.

Trusted download hosts is empty by default and saved with the same Save safety settings button. Enter one bare ASCII DNS hostname per line, such as flow-content.google, or a leading *. wildcard such as *.googlevideo.com. Exact entries match only that hostname. *.example.com matches a.example.com and a.b.example.com, but excludes bare example.com, evilexample.com and example.com.evil.net. Matching ignores case and one trailing dot on both entries and URL hosts; surrounding entry whitespace and duplicates are removed. Exact and wildcard entries can coexist.

Only one leading *. label is allowed, with at least two labels in its base. *, *.com, *.google, other wildcard placements, schemes, paths, queries, ports, userinfo, spaces and IP literals (including alternate IPv4 spellings after *.) are rejected. There is no public suffix list check beyond the single-label restriction, so *.co.uk is accepted and trusts all its subdomains. Invalid lines are ignored with specific reasons for up to three lines, plus a count of additional ignored lines; long entries are shortened in the status. Changes sync immediately; missing or malformed policy trusts no cross-origin hosts. Agents and MCP/control actions cannot change this list.

For Google Flow on labs.google / flow.google.com, take a snapshot and inspect a clip's srcHost. Add *.googlevideo.com for per-node clip hosts such as rr4---sn-h5576n7k.googlevideo.com, and flow-content.google for thumbnails. The wildcard excludes bare googlevideo.com. A native same-origin clip URL can also redirect to storage/CDN hosts. A refusal identifies only the refused hostname, for example Download host storage.googleapis.com is not a trusted download host; add it under "Trusted download hosts" in Extension options. Add only hosts you intend to trust, retry, and repeat if another redirect host is reported. Do not paste signed media URLs into the host list. Cookies stay in Chrome; no session cookies are exported to the daemon.

When interception is needed, the Fetch domain is scoped to HTTP(S) page fetch responses for the download's duration. A unique isolated-script initiator and Network request ID identify the download, so simultaneous page requests to the same URL cannot bypass redirect checks. Ambiguous same-source responses wait for that identity; missing identity fails closed. Other requests continue immediately. Service workers are temporarily bypassed for this tab so worker-owned forwarding cannot hide redirects; normal service-worker routing is restored during cleanup.

Cleanup closes the stream, aborts the page request and disables interception, including on failure, navigation, permission revocation or daemon disconnection. If Chrome cannot confirm cancellation or cleanup, the transaction retains interception and its listener, rejects new transfers for that tab, and retries with bounded CDP calls every five seconds. It continues unrelated traffic and fails its own paused requests. Disabling interception before cancellation is confirmed could release a rejected redirect, so this exceptional state deliberately stays closed until cleanup succeeds or the debugger detaches. The daemon publishes the file only after Chrome cleanup succeeds. Operations have timeouts (120 seconds of inactivity and 10 minutes overall). This does not modify file chooser interception and needs no additional manifest permissions. Chrome cookie restrictions and page CSP may still affect a site's requests; initiator metadata and streaming need live Chrome/Flow validation.

Each download is limited to 500 MB (524,288,000 bytes), checked against Content-Length when present and actual streamed bytes in both the extension and daemon (or the fetched Blob size on the page path). The daemon transfers chunks of up to 1 MB into a private .<name>.<id>.ubb-partial file. It atomically publishes the completed file without overwriting existing files or following final-path symlinks. Collisions become scene (1).mp4, scene (2).mp4, and so on. The result is { "saved": "/real/path/renders/scene.mp4", "bytes": 12345, "mimeType": "video/mp4" }.

Filenames use only the basename, remove control characters, separators, colons and leading dots, and are trimmed to 200 characters while preserving the extension. Empty names are rejected. Subfolders cannot be absolute or contain .. segments; each segment is sanitized, created and resolved within the real allowed folder. Symlink escapes are refused before creating further directories outside that folder.

Errors remove the temporary file. For page-Blob downloads, cleanup also attempts to release the page-held Blob; a two-minute page timer releases abandoned fetches and Blobs when normal cleanup cannot run, and active chunk transfers refresh that timer. The interception path uses an abortable isolated-world request with its own ten-minute safety timer. Download operations recheck site access, source/redirect policy and the selected folder throughout transfer.

Annotations

Share a normal HTTP(S) tab with the toolbar icon, then enter annotation mode with Alt+Shift+A (macOS: Control+Shift+A) or right-click the toolbar icon and choose Annotate this tab. The toolbar's normal click still toggles sharing. If the shortcut conflicts, change it at chrome://extensions/shortcuts.

Hover to highlight an element, click to select it, or drag a rectangle. Enter a comment and click Save. Cancel discards the selection; Esc cancels the popup and exits annotation mode. Numbered pins show comments and reply threads on hover or click. Open pins with replies turn green, gain a badge dot, and have an Answered title and accessible label. Resolved pins stay grey even when they have replies. Updates appear on every shared tab at the same exact URL. Reloading that URL restores its pins. Long threads scroll; reply authors and local timestamps appear above plain-text replies.

Each save captures a PNG of the visible viewport with the annotation overlay hidden. Keep that tab active in its window until saving finishes. Metadata includes the selector, a short text snippet, page rectangle, viewport, scroll position, URL, title and timestamps. Pins follow the selector when it still exists, including the region's saved offset from its anchor; otherwise they use the original page coordinates.

The daemon stores private files in ~/.universal-browser-bridge/annotations/: annotations.json and <id>.png. Set UBB_DATA_DIR on serve to change the data root. The daemon is the sole writer and commits files with temporary-file renames. Annotation queries, resolution and replies also work when Chrome is disconnected.

universal-browser-bridge annotations list --status open --json
universal-browser-bridge annotations list --url 'https://example.com/'
universal-browser-bridge annotations get <id>
universal-browser-bridge annotations get --url 'https://example.com/'
universal-browser-bridge annotations resolve <id> --note 'Fixed the heading'
universal-browser-bridge annotations reply <id> --text 'Fixed the spacing' --author Hephaestus

CLI output is JSON. List defaults to all statuses; URL filters match exactly, including query strings and fragments. get <id> returns one record, and get --url returns an array. Both include an absolute screenshotPath. Resolution is idempotent and preserves the first resolution timestamp and note.

Every record includes replies, an array of {id, author, text, createdAt}. Older records load with an empty array. Reply text is trimmed and must contain 1–10,000 characters. Authors are trimmed and must contain 1–100 characters; omitted authors default to agent. Replying to a resolved annotation preserves its resolved state. annotations reply prints the updated record.

MCP provides the same operations. For an existing ubb <tool_name> '<json>' wrapper:

ubb annotations_list '{"status":"open","url":"https://example.com/"}'
ubb annotations_get '{"id":"<id>"}'
ubb annotations_get '{"url":"https://example.com/"}'
ubb annotations_resolve '{"id":"<id>","note":"Fixed the heading"}'
ubb annotations_reply '{"id":"<id>","text":"Fixed the spacing","author":"Hephaestus"}'

Annotations use the existing token-authenticated WebSocket and control socket, with no additional listener. The extension adds scripting, contextMenus and activeTab, with no broad website host permission. Chrome grants activeTab on a toolbar click, annotation shortcut or context-menu invocation; automatic injection still requires the tab to be shared. Same-origin reloads retain the grant. After crossing origins, or on an agent-created tab without a user gesture, invoke the annotation shortcut or context menu again to grant access. Unsharing removes the overlay.

Annotations target the top-level document. Iframes and elements inside shadow roots cannot be individually selected. Screenshots show only the viewport; coordinates and selectors may become less accurate after major page changes. Data remains local until an agent reads it through the bridge or you enable a webhook.

Annotation webhook

To POST each newly saved annotation to an HTTP(S) endpoint, create <dataRoot>/webhook.json. The data root is UBB_DATA_DIR on serve, or ~/.universal-browser-bridge by default:

{
  "url": "https://hooks.example.com/annotations",
  "headers": {
    "Authorization": "Bearer YOUR_TOKEN",
    "X-Sender-Key": "YOUR_SENDER_KEY"
  },
  "enabled": true
}

Keep the directory private (mode 700) and the config file private (mode 600, for example chmod 600 ~/.universal-browser-bridge/webhook.json). The CLI does not write this file. headers defaults to {} and enabled defaults to true. Set enabled: false to disable delivery. Set UBB_ANNOTATION_WEBHOOK_URL on serve to override the URL and enable delivery, including without a file; valid file headers still apply. File edits are read on the next send without restarting the daemon. A missing file with no override is silently disabled. Invalid JSON/configuration disables delivery, even with an env override, and emits one warning until the configuration becomes valid again.

Each successful new save sends this JSON metadata, never PNG bytes:

{
  "event": "annotation.created",
  "annotation": {
    "id": "00000000-0000-4000-8000-000000000001",
    "comment": "Fix the heading",
    "url": "https://example.com/",
    "title": "Example",
    "textSnippet": "Page heading",
    "selector": "#heading",
    "createdAt": "2026-10-10T10:00:00.000Z",
    "screenshotPath": "/absolute/data/root/annotations/00000000-0000-4000-8000-000000000001.png"
  }
}

Requests always use Content-Type: application/json. User-Agent: universal-browser-bridge is added unless overridden (header names are case-insensitive). Delivery runs asynchronously after saving and never delays or fails the extension's save response. Queries, replies and resolutions do not send webhooks.

Each attempt has a 10-second timeout. Network failures, timeouts, HTTP 429 and 5xx retry up to three times, with 1s, 2s and 4s backoff (four attempts total, about 47 seconds maximum). Other HTTP errors do not retry, and redirects are not followed. Failures log only the endpoint host and HTTP status or a generic error, never header values or URL paths/queries. Retries can deliver the same event more than once; consumers should deduplicate by annotation ID. Delivery is best-effort in memory, with no durable queue or restart recovery.

universal-browser-bridge annotations webhook status
universal-browser-bridge annotations webhook test

Both commands use the authenticated daemon control socket, so serve must be running. status prints {configured, enabled, source, host, headerNames} with source file, env or none, and never prints header values. test uses the same sender, headers, timeout and retry policy to send {"event":"annotation.test","sentAt":"<ISO timestamp>"}. It prints {ok, attempts} plus a status code or error, and exits non-zero for disabled configuration or failed delivery.

After updating, reload the extension at chrome://extensions and rebuild/restart the daemon and MCP adapters. Live annotation interaction in Chrome is a separate manual check from the automated mock, socket and process tests.

Available MCP tools

Tool

Purpose

browser_status

Show extension connection state, shared tabs and caller-owned tabs

browser_tabs

List shared tabs and caller-owned tabs

browser_new_tab

Open a tab in the agent window; labelled ownership survives reloads when its exact group and window still validate

browser_list_agent_tabs

List the caller label's current or validated restored tabs as tabId, url, title and groupId

browser_close_tab

Close a caller-owned tab or a tab otherwise legitimately shared with the caller

browser_snapshot

Read compact interactive elements and receive a snapshotId

browser_navigate

Navigate a shared tab to an allowed URL

browser_click

Scroll to and click a snapshot element with trusted mouse input (script fallback); consequential approval applies unless the caller's owned tab is trusted

browser_type

Replace the contents of a snapshot input element with user activation

browser_upload_file

Attach up to 10 allowed local files through an input or intercepted chooser in a caller-owned tab; modes: auto, input, chooser

browser_download

Save same-origin, trusted HTTPS (exact hosts or explicit leading *. subdomain wildcards), blob or data media from a caller-owned tab; validate every HTTPS redirect; discover hostnames via snapshot/error; up to 500 MB in an allowed folder

browser_press

Send trusted keyboard input; Enter and NumpadEnter require approval unless the caller's owned tab is trusted

browser_scroll

Scroll a shared page by pixels

browser_screenshot

Capture a PNG screenshot of a shared tab

annotations_list

List saved annotations by exact URL and status

annotations_get

Get an annotation by ID or all annotations for a URL

annotations_resolve

Resolve an annotation and update live pins

annotations_reply

Append a reply and update live pins, including on resolved annotations

browser_click and browser_type require both the element ref and snapshotId returned by browser_snapshot. Take another snapshot after navigating.

browser_click scrolls the element into view and checks its center with document.elementFromPoint. When the center is visible, has size and hits the element or a descendant, it sends trusted CDP mouse move, left-button press and release events. Zero-size, covered or offscreen targets use a script click with userGesture: true instead. The result is { clicked: true, method: "mouse" | "script" }. The fallback click event remains synthetic, but the script runs with user activation for actions such as downloads and file pickers. browser_type also runs its focus/value script with user activation. browser_press uses trusted CDP key events and retains approval for Enter and NumpadEnter.

Running automatically

The daemon is deliberately a foreground process by default. For long-running use, supervise it with a tool appropriate to your operating system, such as:

  • macOS: LaunchAgent

  • Linux: systemd user service

  • Windows: Task Scheduler or a user service wrapper

  • Cross-platform development: tmux or another terminal multiplexer

The daemon must run under the same user account that owns the token and control socket. Do not expose it through a public proxy.

Standalone binary

With Bun installed, compile the CLI and dependencies into a self-contained executable:

npm run binary

Output:

bin/universal-browser-bridge

Run it with:

./bin/universal-browser-bridge token
./bin/universal-browser-bridge serve
./bin/universal-browser-bridge mcp

Cross-compile by selecting a Bun target such as bun-linux-x64, bun-windows-x64, bun-darwin-x64, or bun-darwin-arm64.

Configuration

Variable

Applies to

Purpose

UBB_TOKEN_FILE

serve, mcp, token, annotations

Token path; defaults to ~/.universal-browser-bridge/token

UBB_TOKEN

serve, mcp, token, annotations

Supply the token directly instead of reading a file

UBB_SOCKET_PATH

serve, mcp, annotations

Control socket path; defaults to ~/.universal-browser-bridge/daemon.sock or a Windows named pipe

UBB_DATA_DIR

serve

Annotation data root; defaults to ~/.universal-browser-bridge

UBB_ANNOTATION_WEBHOOK_URL

serve

Override and enable the annotation webhook URL; retain valid file headers

UBB_PORT

serve

Extension WebSocket port; defaults to 17321

UBB_CLIENT_LABEL

mcp, CLI tools

Persistent agent ownership label, group title and approval identity; --label overrides it

UBB_ALLOW_PRIVATE_NETWORKS

mcp

Set to 1 to allow localhost and private-network navigation

If you change UBB_PORT, update the extension Relay URL to match:

ws://127.0.0.1:<port>/extension

Token rotation

To invalidate the current token:

  1. Stop the daemon and all MCP adapters.

  2. Delete or move ~/.universal-browser-bridge/token.

  3. Run npm run token to generate a fresh token.

  4. Paste the new token into the extension Options page.

  5. Restart the daemon and MCP clients.

A token rotation disconnects every process still using the previous token.

Testing

Automated tests without Chrome

npm run check
npm test
npm run build

The test suite includes real local sockets and real subprocesses. It starts a daemon, multiple MCP adapters, and a fake extension WebSocket peer. No Chrome interaction is required.

Restricted environments can run the socket-free tests, including the real extension/bridge/daemon/MCP ownership, trust and blocklist flows over mocked socket and Chrome boundaries:

npm test -- --exclude src/bridge.test.ts --exclude src/daemon.test.ts --exclude src/background-owner-race.test.ts --exclude src/cli-integration.test.ts

Those four files require local listeners, and the CLI integration also requires the tsx IPC socket. An EPERM listener failure is an environment restriction; the socket-free run does not replace real-socket or live Chrome acceptance.

Live Chrome E2E

First load and pair the extension, then run:

npm run e2e

The E2E suite:

  • Starts a real daemon

  • Connects two MCP adapters simultaneously

  • Creates an agent-owned tab

  • Verifies cross-adapter ownership isolation

  • Exercises snapshots, navigation, keys, scrolling, clicking, typing, and screenshots

  • Rejects file:// navigation

  • Rejects stale snapshot generations

  • Waits 45 seconds to verify Manifest V3 service-worker keepalive

  • Closes the created tab and cleans up all subprocesses

By default the E2E daemon uses an ephemeral port to avoid colliding with an existing daemon. The script prints the selected URL; update the extension Relay URL to that value. To use a stable test port:

E2E_PORT=17322 npm run e2e

Then set the extension Relay URL to:

ws://127.0.0.1:17322/extension

The agent-controlled window opens without a prompt by default. If you turned off Let agents open their window without asking, approve the window creation manually; the test never approves Chrome prompts itself.

Troubleshooting

Extension does not connect

  1. Confirm serve is running.

  2. Confirm the Relay URL uses the daemon's printed port.

  3. Confirm the token in extension Options matches npm run token.

  4. Click Save and connect after editing either field.

  5. Confirm Chrome's Loaded from path points to the intended extension/ directory.

  6. Reload the extension after changing extension/*.js.

  7. Restart the daemon after rebuilding src/*.ts.

You can inspect daemon health locally:

curl http://127.0.0.1:17321/health

A successful pairing reports "connected": true.

The extension sends its existing 20-second ping while connected. A ubb-keepalive Chrome alarm runs every 30 seconds and reconnects when the socket is closed or unauthenticated, including after the service worker has stopped during a longer daemon outage. This version adds the alarms permission; reload the extension to apply the manifest change.

Daemon logs and detach diagnostics

serve writes logs to stderr, shown in its terminal or captured by your process supervisor. There is no built-in log file. To capture one yourself:

npm run serve 2>>/tmp/ubb-daemon.log

Every daemon log line, including startup, shutdown, webhook errors and diagnostics, has a local ISO timestamp with its UTC offset, for example [2026-10-20T12:34:56.123+05:30]. MCP stdout and CLI JSON output retain their existing format.

The log records extension connection/disconnection, restored tab ownership, and structured extension diagnostics with event, tabId, reason, optional label, and the event's at timestamp. Detach diagnostics use event: "debuggerDetached" and preserve Chrome's reason. Up to 50 diagnostics are queued in the worker while disconnected and flushed on reconnection.

canceled_by_user immediately revokes access, removes durable ownership and cancels recovery. For other reasons such as target_closed, a labelled tab that still exists in its exact owner group and window gets one re-attach attempt after about 500 ms. Attempts are limited to three per tab per minute. Commands are refused while recovery is pending. Failed validation, attachment failure or the rate limit drops ownership and is logged; a real closed tab cannot be recovered. Successful recovery logs debuggerReattached. Hand-shared tabs retain the existing revocation behavior.

Toolbar badge meanings

Badge

Meaning

No badge

Connected or not currently reporting an error

?

Pairing token is missing from extension settings

!

Relay is disconnected

ON

This tab is explicitly shared

ERR

Tab sharing failed; hover the toolbar icon to see the reason

ERR badge when sharing a tab

Hover the toolbar icon to see why sharing failed.

  • Cannot access a chrome-extension:// URL of different extension means another extension (e.g. a password manager or Grammarly) has a frame in the page. Open chrome://extensions, select that extension's Details, set Site access to On click, and reload the tab before trying again.

  • Another debugger is already attached means DevTools or another debugging extension is attached to that tab. Close DevTools or disconnect the other extension from the tab, then try again.

EADDRINUSE on port 17321

Another daemon or older single-process bridge is already listening.

lsof -nP -iTCP:17321 -sTCP:LISTEN   # macOS/Linux

Stop the old process or choose another UBB_PORT. Only one serve daemon should own a given extension port.

MCP reports that the daemon is not running

Start serve separately. The mcp command intentionally does not launch a hidden daemon:

npm run serve

Shared tab disappears

  • Confirm the tab remains in its exact label group, or Agent Bridge for a hand-shared tab.

  • Renaming the group or dragging the tab out revokes access by design.

  • Clicking the extension icon toggles sharing.

  • Chrome may refuse debugger attachment while DevTools is attached to the same target.

  • Inspect the daemon's debuggerDetached, ownershipDropped and debuggerReattachFailed diagnostics for the reason.

New unpacked folder lost its settings

Chrome assigns unpacked extensions an identity based partly on their loaded directory. Loading the same files from another checkout or worktree may create a separate extension with empty storage. Re-enter both the Relay URL and pairing token.

Changes are not taking effect

  • TypeScript/daemon change: run npm run build, then restart serve and MCP adapters.

  • Extension JavaScript change: click Reload in chrome://extensions.

  • Manifest change, including the new alarms permission: reload the extension. Labelled tabs restore only after their stored group and window pass validation.

  • MCP client configuration change: restart the MCP client or start a new agent session.

Security boundaries

  • All network listeners bind to IPv4 loopback.

  • The extension accepts only chrome-extension:// WebSocket origins with the pairing token.

  • The local control socket is owner-only and also token-authenticated.

  • Only explicitly shared tabs are attached through chrome.debugger.

  • Password input values are omitted from snapshots.

  • File uploads require live ownership by the caller's label, writable/unblocked sites, and user-configured allowed folders; the daemon validates real paths, file types and sizes, and the extension independently guards attachment.

  • Downloads require a caller-owned, writable, unblocked tab and a separately configured allowed download folder. The daemon and extension independently enforce trusted HTTPS hosts (exact entries or explicit leading *. wildcards that exclude the apex), every redirect before continuation (10-hop cap), the tab blocklist and destination-host blocklist. Wildcard bases require at least two labels; there is no public suffix list check, so *.co.uk is accepted. Trusted hosts default to empty and can only be changed in Extension options. Cookies remain inside Chrome. Source discovery and refusal errors expose hostnames only, never signed source paths or queries. The daemon enforces the 500 MB cap and real destination paths; the extension also checks streamed size, ownership, site access and the selected folder. Chunk transfers use private temporary files and atomic publication without overwrite. Streams and interception are cleaned up on failure. MCP tools cannot change download permissions.

  • Chooser uploads retain those checks and consequential-click approval. The extension rechecks access before each operation, limits interception to one upload per tab, and restores the picker shim and interception after the call. If access is revoked mid-call, cleanup still attempts to disable interception and restore the shim.

  • Navigation and new tabs accept only allowed http: and https: URLs.

  • Localhost and private-network destinations are blocked unless explicitly enabled.

  • Consequential clicks and Enter presses require Chrome-hosted human approval unless the extension's trust setting is enabled and the live tab belongs to the caller's label.

  • Creating the agent window requires approval only when Let agents open their window without asking is turned off. It is enabled by default, so any local process holding the bridge token can open agent tabs in the signed-in Chrome without a prompt.

  • Bank, card, payment and password-manager pages always remain read-only, regardless of either trust or automatic window creation settings.

  • The daemon rechecks writable policy before CDP calls marked requiresWritable. The extension independently treats every Input.* method and DOM.setFileInputFiles as a write, even if the caller omits that flag. Trusted input and script user activation retain tab ownership, blocklist, snapshot and approval checks.

  • Blocklisted domains and their subdomains refuse all page operations in owned and hand-shared tabs, with current and pending URL checks in both the daemon and extension.

  • The model cannot approve its own request.

  • Agent-created tabs belong to their creating adapter while it is connected.

  • Durable labelled ownership requires a stored record and an exact label/group/window match. Group titles alone never grant ownership. Explicit debugging cancellation and revoked durable records cannot trigger automatic re-attachment or restoration.

  • Per-tab queues prevent concurrent agents from interleaving actions.

  • Snapshot generations prevent old references from acting after navigation.

  • Dragging a tab out of the Agent Bridge group revokes access.

Do not share banking, password-manager, payment, healthcare, or other highly sensitive tabs. Web content can contain prompt injection, and the consequential-action heuristic cannot recognize every risky interaction.

Known limitations

  • A local malicious process that binds the extension port first could capture the pairing token when the extension connects. Rotate the token if you suspect local compromise.

  • Client identity is process-scoped. A harness that launches a fresh MCP adapter per turn or subagent gets a new identity.

  • UBB_CLIENT_LABEL improves attribution but is not an authorization credential.

  • The confirmation heuristic is conservative but not comprehensive.

  • The extension is currently distributed as an unpacked extension rather than through the Chrome Web Store.

  • The daemon is not installed as an operating-system service automatically.

Development notes

  • Extension code is plain JavaScript; no browser build step is required.

  • Daemon and MCP code is TypeScript compiled to dist/.

  • npm test covers unit, socket, process, ownership, and Chrome-event-ordering behavior.

  • The live E2E was last verified at 17/17 passing with two simultaneous adapters and real Chrome.

Roadmap

  • Stable logical identities and expiring grants

  • Domain policies and sensitive-site defaults

  • Append-only action audit log

  • Prompt-injection detection and untrusted-page labelling

  • Native adapters for additional agent harnesses

  • Chrome Web Store packaging and reproducible releases

License

MIT. See LICENSE.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    Connects AI agents to your Chrome browser via MCP, enabling real-time control of existing tabs, sessions, and application state for development workflows.
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables remote AI agents to control a local, authenticated desktop browser over MCP through an outbound WebSocket bridge and Chrome extension, supporting tab management, navigation, page reading, clicking, typing, scrolling, screenshots, and JavaScript evaluation.
    1
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables AI agents to create, read, and operate granted background tabs in Chrome via a local stdio MCP server, preserving the human's front tab. Supports tab groups, navigation, clicking/typing, screenshots, and human-priority permissions.
    MIT