Skip to content

Protocol (v2)

Two layers: the messaging envelope (how frames move) and the message catalog (what payloads mean). Both are defined in code — packages/messaging and packages/protocol — so this doc is a map, not a second source of truth.

Messaging envelope — packages/messaging

A transport-agnostic Peer on each end of the WebSocket. Frames:

FrameShapeMeaning
request{ k: "req", id, type, payload }expects a response
response{ k: "res", id, ok, result? , error? }reply to a request id
notify{ k: "ntf", type, payload }fire-and-forget
ping / pong{ k: "png" } / { k: "pog" }liveness
  • peer.request(type, payload, {timeoutMs}) correlates by an incrementing id and rejects on timeout (RequestTimeoutError).
  • peer.notify(type, payload) sends without expecting a reply.
  • Either side can send requests. The server sends tool calls to the extension; the extension sends hello to the server and tabs_changed notifications.
  • On disconnect, all in-flight requests reject. Only idempotent reads are retried after a timeout (retryableMessages in packages/protocol), never click/type/navigate.

The Peer needs a PeerSocket adapter (send / close / onMessage / onClose). The server builds it from ws (apps/server/src/ws-adapter.ts); the extension builds it from the browser WebSocket (apps/extension/entrypoints/offscreen/main.ts).

Wire message catalog — packages/protocol/src/messages.ts

Server → extension requests. Every tab-scoped payload carries an optional tabId (omit → active tab); responses echo the resolved tabId.

typerequestresponse
browser_navigate{ url, tabId? }{ tabId }
browser_go_back / browser_go_forward{ tabId? }{ tabId }
browser_click{ ref, element, tabId? }{ tabId }
browser_type{ ref, element, text, submit?, tabId? }{ tabId }
browser_hover{ ref, element, tabId? }{ tabId }
browser_select_option{ ref, element, values, tabId? }{ tabId }
browser_press_key{ key, tabId? }{ tabId }
browser_get_console_logs{ tabId? }{ tabId, logs[] }
browser_screenshot{ tabId? }{ tabId, data } (base64 png)
browser_snapshot{ tabId? }{ tabId, url, title, snapshot } (one round-trip)
getUrl / getTitle{ tabId? }{ tabId, url } / { tabId, title }
list_tabs{}{ tabs: TabInfo[] }
browser_switch_tab{ tabId }{ activeTabId }

browser_wait is handled server-side (a sleep) — it never hits the extension.

Control messages — packages/protocol/src/handshake.ts

Extension-initiated:

typedirectionpayload
hello (request)ext → server{ protocol, profileId, label, slug?, chromeProfileName?, extVersion, tabs[] }
hello_ack (response)server → extok: { ok: true, protocol, serverVersion, assignedLabel, assignedSlug } · rejected: { ok: false, protocol, serverVersion, reason }
tabs_changed (notify)ext → server{ tabs: TabInfo[] }

hello runs before anything else on a socket; a socket that doesn't say hello within 10s is dropped. protocol is the wire version (PROTOCOL_VERSION, currently 1), required in the hello and echoed in the ack — a mismatch on either side is rejected with a message naming both numbers, never best-effort. The ack's assignedLabel + assignedSlug are what the server chose (the hello's label, or a generated default like chrome-1); the extension persists both and re-announces the slug on every hello, so slugs survive server restarts. hello_ack.ok === false (protocol mismatch, profile cap) makes the extension surface reason and retry slowly.

Migration (0.2 ↔ 0.3): a legacy 0.2-shaped hello (no protocol field) is parsed with a lenient pre-schema so it still gets an explicit, parseable "update the extension" rejection — that ack carries a vestigial assignedPort: 0 field solely so the 0.2 ack parser can read it. In the other direction, a 0.3 extension hitting a 0.2 server gets an ack with assignedPort and no protocol, which its HelloAck schema refuses — it surfaces "update the server (npx monkbrowse@latest)".

Session control messages — packages/protocol/src/control.ts

Between monkbrowse processes on the control port (:9219): one process wins the bind and becomes the owner; the others become forwarders that proxy tool calls to it (see plans/2026-07-31-shared-daemon-design.md). Same Peer envelope:

typedirectionpayload
fwd_hello (request)fwd → owner{ pid, version, token } → ack { ok, version, reason? }
call_tool (request)fwd → owner{ name, args }{ content, isError? }

token is required: the owner writes a random token to ~/.monkbrowse/control-token (mode 0600) and every forwarder must echo it — same-user native processes can read the file, web pages cannot, so a wrong or missing token is rejected. The control listener also refuses any WebSocket upgrade carrying an Origin header (browsers always send one; native processes don't), and call_tool is rejected until the connection's fwd_hello has been accepted.

AI-facing tool schemas — packages/protocol/src/tools.ts

These are separate from the wire messages: they add profile? (a string — the profile's slug, a unique prefix of it, or its profileId) and tab? — the simple per-profile number shown in the popup — for the AI to target a profile+tab. The server (apps/server/src/tools/index.ts) resolves profile to a connection and maps tab (a slot) to the real chrome tabId, which is what gets threaded into the wire payload. Schemas convert to JSON Schema via the @monkbrowse/protocol/json-schema subpath for MCP tools/list.

Internal extension bridge (not on the wire)

Inside the extension, the offscreen doc and service worker talk over chrome.runtime messages tagged with to: "bg" | "off" and a kind (exec, hello-info, socket-status, tabs-push, reconnect, …) — see apps/extension/lib/constants.ts. Content-script messages are tagged cs: true. These never leave the browser.

Apache-2.0 licensed. Privacy