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:
| Frame | Shape | Meaning |
|---|---|---|
| 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
helloto the server andtabs_changednotifications. - On disconnect, all in-flight requests reject. Only idempotent reads are retried after a timeout (
retryableMessagesinpackages/protocol), neverclick/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.
| type | request | response |
|---|---|---|
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:
| type | direction | payload |
|---|---|---|
hello (request) | ext → server | { protocol, profileId, label, slug?, chromeProfileName?, extVersion, tabs[] } |
hello_ack (response) | server → ext | ok: { 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:
| type | direction | payload |
|---|---|---|
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.