Asyar Browser Bridge — Protocol Specification
Version 1. Stable wire contract for browser-side companion extensions.
The Asyar launcher hosts a local HTTP + WebSocket server bound to 127.0.0.1
on the first available port in the range 54300–54320. Companion browser extensions discover the port, request
pairing, await user approval inside the launcher, then maintain a persistent
WebSocket connection authenticated by a token.
1. Discovery
GET http://127.0.0.1:<port>/discover
→ { "name": "asyar", "version": "<launcher version>" }
The launcher binds the first available port in the range 54300–54320, which is
exactly why probing that range finds it. Probe the range in parallel with
short-timeout fetches. The first port returning a name == "asyar"
response is the running launcher. Cache the port in extension storage; retry
discovery if the cached port stops responding.
2. Pairing
POST http://127.0.0.1:<port>/pair-request
Body: { "family": "chromium" | "firefox" | "safari", "variant": "chrome" | "brave" | ... }
→ { "pairing_id": "<uuid>" }
The launcher displays a confirmation dialog. The companion long-polls for the result:
GET http://127.0.0.1:<port>/pair-status/{pairing_id}
(blocks up to 60s)
→ { "status": "approved", "token": "<base64url, 43 chars>" }
| { "status": "denied" }
| { "status": "timed_out" }
| { "status": "unknown" }
Store the token in browser extension storage. Treat it as a long-lived secret — do not transmit anywhere except the WebSocket Authorization header.
3. WebSocket connection
WS ws://127.0.0.1:<port>/bridge?family=<family>&variant=<variant>
Header: Authorization: Bearer <token>
Authentication channels
The token may be supplied two ways:
-
Authorization: Bearer <token>header — for non-browser clients (native apps, tests). BrowserWebSocketcannot set request headers, so browsers must use channel 2. -
Sec-WebSocket-Protocolsubprotocol (browser companions) — offer two subprotocols: the markerasyar.v1andbearer.<token>:new WebSocket(`ws://127.0.0.1:${port}/bridge?family=chromium&variant=chrome`, [ 'asyar.v1', `bearer.${token}`, ]);The launcher extracts the token from the
bearer.entry and echoes backasyar.v1as the selected subprotocol. (Browsers close the socket if the server does not select one of the offered subprotocols.)
Send hello as the first message:
{
"type": "hello",
"version": 1,
"browser": { "family": "chromium", "variant": "chrome", "profiles": ["Default", "Profile 1"] }
}
4. Message envelopes
Companion → server
| { "type": "hello", "version": 1, "browser": { family, variant, profiles[] } }
| { "type": "event", "name": "tabs.snapshot" | "tabs.changed", "payload": Tab[] }
| { "type": "event", "name": "page.changed", "payload": { tabId: string, page: PageSnapshot } }
| { "type": "event", "name": "window.focused", "payload": {} }
| { "type": "res", "id": string, "ok": true, "result": any }
| { "type": "res", "id": string, "ok": false, "error": string }
window.focused note: the companion emits { type: 'event', name: 'window.focused', payload: {} } whenever its browser window gains focus (the companion ignores WINDOW_ID_NONE — i.e. focus-loss events — and only fires when a real window ID is received). The launcher tracks the most-recently-focused browser (last_active) so it can route newly-opened content to the browser the user is actually using.
Server → companion
| { "type": "req", "id": string, "method": "tabs.activate" | "tabs.close" | "tabs.open", "params": any }
| { "type": "req", "id": string, "method": "page.snapshot" | "page.query" | "page.action", "params": any }
| { "type": "req", "id": string, "method": "search.web", "params": { text: string } }
5. Tab shape
{
id: string, // stable for the lifetime of this WS session
browser: { family, variant, profileId },
windowId: string,
index: number,
title: string,
url: string,
faviconUrl?: string,
isActive: boolean,
isPinned: boolean,
isAudible: boolean,
groupName?: string,
}
The companion is responsible for:
- Sending an initial
tabs.snapshotafterhello - Sending
tabs.changedwhenever any tab is created, removed, navigated, activated, or moved between windows - Including the full current tab list in every
tabs.changed(no incremental diffs in v1)
6. Page shape
{
url: string,
title: string,
readableText: string,
html?: string,
selection?: string,
meta: {
description?: string,
ogImage?: string,
lang?: string
}
}
The companion is responsible for sending page.changed when the active tab's page content fundamentally changes (e.g., DOM mutations indicating a navigation or major update in an SPA).
PageMatch shape (response to page.query)
{
tag: string, // lowercase element tag, e.g. "a"
attrs: Record<string, string>, // all values MUST be strings
textContent: string,
}
Companion contract — stringification: all attrs values are strings. If an
attribute's source value is numeric, boolean, or null, the companion stringifies
it before sending (42 → "42", true → "true", null → "null" or omit
the key entirely — companion's choice). The launcher rejects non-string values
at deserialize time so a mis-behaving companion fails loudly rather than
corrupting downstream extension code.
Optional attrs filter on page.query
When the request omits attrs (or sends attrs: undefined), the companion
returns its default set of attributes for each match (typically all standard
HTML attributes). When the request sends attrs: [...], the companion returns
only those keys. An empty array attrs: [] means "return no attribute keys".
These three states are distinct and the companion must honor them.
7. Server-initiated methods
| Method | Params | Result |
|---|---|---|
tabs.activate |
{ tabId: string } |
{ activated: true } |
tabs.close |
{ tabId: string } |
{ closed: true } |
tabs.open |
{ url: string, newWindow: bool } |
{ tabId: string } |
page.snapshot |
{ tabId: string } |
PageSnapshot |
page.query |
{ tabId, selector, attrs? } |
PageMatch[] |
page.action |
{ tabId, action: { kind } } |
null |
search.web |
{ text: string } |
{ searched: true } |
The companion is expected to respond within 5 seconds for tab methods and search.web, and 10 seconds for page methods. The launcher times out RPCs after these durations and returns an error to the calling extension.
search.web: uses the browser's configured default search engine via chrome.search.query (opens a new tab with the search results). 5-second timeout.
tabs.open with newWindow: true: opens a genuinely new browser window via chrome.windows.create (rather than a new tab in an existing window).
Window focus (required for tabs.activate / tabs.open): the companion MUST
also focus the tab's window — chrome.windows.update(windowId, { focused: true })
(or the browser's equivalent) — so the browser comes to the foreground.
tabs.update(..., { active: true }) only changes the active tab within the
browser; without focusing the window the switch happens invisibly in a
background window. The launcher dismisses itself after dispatching the
activation, leaving the focused browser window unobstructed.
8. Revocation
The user can revoke pairing in the launcher's Settings → Browsers UI. The
companion's next WS connect attempt will fail with HTTP 401. The companion
should detect 401 on /bridge, clear its stored token, and offer the user
the option to re-pair.
9. Security model
- Bridge listens on
127.0.0.1only. Never bind to0.0.0.0. - All connections authenticated by a per-browser token.
- The token is generated by the launcher using a CSPRNG (32 random bytes, base64url-encoded, 43 chars no padding).
- The token is stored:
- Launcher side: OS keychain (macOS keychain / Linux secret-service / Windows credential manager)
- Companion side: browser extension storage
- The user must approve pairing inside the launcher UI. The browser-side flow cannot complete pairing without the human acting on the launcher.
10. Versioning
The version: 1 field in hello declares the protocol version the companion
speaks. Future protocol versions will add new event names or methods without
breaking v1 — the launcher inspects this field to decide which features to
expose.