Browser Extension Bridge Protocol
September 2, 2026 · View on GitHub
The CLI can drive the user's real browser through the opentask extension instead of a Playwright-launched one, and mirror the chat conversation into the extension. This document is the wire contract the extension implements.
Transport
- The CLI listens on
ws://127.0.0.1:<port>/ws(default port52789,browser_use.yaml→extension.port). The extension dials in — MV3 service workers cannot listen. - Every frame is a single JSON text message with a
typediscriminator. Unknowntypevalues MUST be ignored (forward compatibility). - Auth is a shared token (
extension.tokeninbrowser_use.yaml, copied into the extension options). Browser WebSocket clients cannot set headers, so the token rides in the first message. - One extension connection at a time; a newly authenticated connection replaces the previous one (service workers restart at will). The CLI sends WS pings every ~20s to keep the service worker alive.
- Only
chrome-extension://,moz-extension://,safari-web-extension://(or absent)Originheaders are accepted.
Enable with:
# ~/.infer/browser_use.yaml
enabled: true
backend: extension
extension:
port: 52789
token: <shared secret; infer init seeds one>
Handshake
Extension → CLI, first frame, within 5 seconds of connecting:
{"type": "browser_hello", "token": "<shared secret>", "extension_version": "1.9.2"}
CLI → extension on success (on failure the socket is closed):
{"type": "browser_hello_ack"}
Browser commands (CLI → extension)
One shape, six actions; only the fields relevant to the action are set.
timeout_ms is the per-action budget the extension must enforce.
{"type": "browser_command", "id": "<uuid>", "action": "navigate", "url": "https://example.com", "timeout_ms": 30000}
{"type": "browser_command", "id": "<uuid>", "action": "click", "selector": "button.submit", "timeout_ms": 30000}
{"type": "browser_command", "id": "<uuid>", "action": "type", "selector": "input[name=q]", "text": "hello", "press_enter": true, "timeout_ms": 30000}
{"type": "browser_command", "id": "<uuid>", "action": "read", "selector": "", "timeout_ms": 30000}
{"type": "browser_command", "id": "<uuid>", "action": "screenshot", "timeout_ms": 30000}
{"type": "browser_command", "id": "<uuid>", "action": "tabs", "timeout_ms": 30000}
navigate: open the URL in the controlled tab (chrome.tabs.update, orchrome.tabs.createwhen none exists). The extension chooses/owns the controlled tab; the protocol has no tab id yet (additive later).click:document.querySelector(selector).click()semantics.type: replace the element's value withtext, dispatchinput/change, then a keyboard Enter whenpress_enteris true.read:innerTextof the selector (empty selector meansbody). Must redact secrets: never return thevalueof<input type="password">, inputs whoseautocompleteiscurrent-password/new-password/one-time-code, or inputs whose name/id/aria-label matches/pass|secret|token|otp|cvc|card/i— substitute"[redacted]".innerTextalready excludes input values; this rule binds any richer extraction.screenshot: capture the visible controlled tab (chrome.tabs.captureVisibleTab) and return it as base64 in the result'simagefield. Passwords render masked by the browser, so no extra redaction is required.tabs: enumerate the open tabs (chrome.tabs.query) and return them in the result'stabsarray, flagging the controlled/active one.
Extension → CLI, exactly one result per command id:
{"type": "browser_result", "id": "<uuid>", "url": "https://example.com/", "title": "Example", "content": "...", "events": [], "error": ""}
{"type": "browser_result", "id": "<uuid>", "image": "<base64>", "image_mime_type": "image/png", "url": "...", "title": "..."}
{"type": "browser_result", "id": "<uuid>", "tabs": [{"index": 0, "url": "...", "title": "...", "active": true}]}
error != ""means the command failed; other fields may be empty.contentis only meaningful forread;image/image_mime_typeforscreenshot;tabsfortabs.eventscarries optional browser-initiated notices (console lines etc.) and may always be empty.url/titlereflect the controlled tab after the action.
Conversation sync
The panel drives which conversation it shows. The CLI does not auto-send a snapshot on connect — the panel lists and resumes conversations explicitly.
Extension → CLI, list the user's stored conversations (the same ones infer
resumes from, under ~/.infer/projects/<project-slug>/conversations):
{"type": "list_conversations"}
CLI → extension, newest-first (sorted by updated_at descending):
{"type": "conversations", "conversations": [{"id": "<uuid>", "title": "...", "updated_at": "2026-08-16T12:00:00Z", "message_count": 12}]}
titleis the conversation's title (an auto-derived first-message preview until a better one is generated);updated_atis RFC 3339;message_countis the number of stored messages.- The array is empty when the CLI runs without conversation persistence
(
storage.enabled: false).
Extension → CLI, resume one — makes it the active conversation:
{"type": "resume_conversation", "id": "<uuid>"}
CLI → extension, the resumed conversation's history:
{"type": "conversation_snapshot", "messages": [{"role": "user", "content": "..."}, ...]}
messagesare the gateway SDK message objects of the resumed conversation. Assistant entries keep theirtool_calls(id, function name, arguments) and tool entries theirtool_call_id, so the panel can rebuild tool rows.tool_resultsmapstool_call_idto whether that execution succeeded, for entries the CLI has an execution record for.- An unknown or empty
idis ignored (no snapshot is sent).
Extension → CLI, start a fresh conversation (the panel's "new session" and the
Install flow use this instead of a /clear chat message — the frame is handled
synchronously in the read loop, so a user_message sent immediately after is
guaranteed to land in the new session):
{"type": "new_session"}
The CLI answers with an empty conversation_snapshot for the new conversation.
After the snapshot the CLI streams live chat activity for the active
conversation, one frame per
AG-UI event (same encoding as
infer headless --output ag-ui, see docs/ag-ui-output.md):
{"type": "chat_event", "event": {"type": "TEXT_MESSAGE_CONTENT", "delta": "..."}}
The extension renders these however it likes; ignoring event types it does not understand is expected.
Extension → CLI, to send a user message into the conversation (queued if the agent is busy, exactly like typing in the TUI):
{"type": "user_message", "content": "please also check the docs page"}
Extension → CLI, to stop the turn currently streaming (same as esc in the
TUI; a no-op when nothing is running):
{"type": "interrupt"}
Whenever a turn ends cancelled - whether stopped from the panel or from the
terminal (esc/Ctrl+C) - the CLI sends an explicit frame so the panel can
clear its working state even when the cancel happened mid tool call and no
TEXT_MESSAGE_END chat event follows:
{"type": "interrupted"}
Input history
The panel's arrow-up recall shares the CLI's shell-style input history: the CLI
appends panel-sent user_message frames to the same store the TUI's arrow-up
navigation uses (trimmed, consecutive duplicates skipped), and serves the
combined history back on request.
Extension → CLI, list recent input history (typically sent after the hello ack):
{"type": "list_history"}
CLI → extension, the most recent entries, oldest first (empty when history storage is unavailable; capped at 1000 entries):
{"type": "history", "history": ["fix the tests", "task check"]}
Multi-line entries round-trip with real newlines in the JSON strings.
Skills
The panel offers a "/" autocomplete of the agent's skills. It asks the CLI for
the merged, scope-tagged list the CLI already resolves (project, .agents,
user, plugin, catalog), so the menu mirrors what the TUI offers.
Extension → CLI, list the available skills:
{"type": "list_skills"}
CLI → extension, the discovered skills (empty when skills are unavailable):
{"type": "skills", "skills": [{"name": "tmux", "description": "...", "scope": "user"}]}
nameis the qualified skill name (pluginName:skillNamefor plugin skills).scopeis one ofproject,agents,user,plugin,catalog; name conflicts are already resolved by precedence, so each name appears once. Unknown scopes are ignored by the extension.
Models
The extension offers model pickers (e.g. the default model when installing the task workflow). It asks the CLI for the models the CLI itself is configured with, so the extension never hardcodes a model list.
Extension → CLI:
{"type": "list_models"}
CLI → extension, the configured model ids (empty when unavailable):
{"type": "models", "models": ["anthropic/claude-sonnet-4-5", "ollama_cloud/deepseek-v4"], "current": "anthropic/claude-sonnet-4-5"}
- Each entry is a
provider/modelid exactly as the CLI would accept it. - The first entry is the CLI's default model.
Extension → CLI, switch the CLI's active model (same effect as /model in the
TUI). The CLI answers with a fresh models frame; current shows whether the
switch took effect:
{"type": "select_model", "model": "openai/gpt-4o"}
Agent mode
The panel can toggle the CLI's agent mode (the same shared state as the TUI's
shift+tab cycle; it also governs tool_request approvals). Modes travel as
their canonical keys: standard, plan, auto, auto-with-judge.
CLI → extension, sent on hello and after every set_mode:
{"type": "mode", "mode": "standard"}
Extension → CLI, switch the mode. Unknown values are ignored; the CLI answers
with a fresh mode frame either way:
{"type": "set_mode", "mode": "auto"}
Artifacts (generated images)
Chat text can reference files the agent saved under the artifacts dir
(~/.infer/projects/<project-slug>/artifacts/<...>, e.g. ImageGeneration
output). An MV3 extension
cannot load a local file path in <img>, so alongside /ws the CLI serves that
directory read-only over HTTP:
GET http://127.0.0.1:<port>/artifacts/<relative-path>
The extension rewrites a markdown image whose URL contains /artifacts/
to this route (stripping the prefix through and including artifacts/) and
renders it inline. The route is loopback-only and unauthenticated (the artifacts
are the user's own generated files); path traversal is blocked by http.Dir.
Tool approvals
When a tool call needs the user's approval, the CLI sends an approval request instead of mirroring it as a chat line. The extension shows Approve/Deny and sends the decision back. The same decision can still be made in the terminal — whichever answers first wins; the loser is a harmless no-op.
CLI → extension, one per pending tool call:
{"type": "approval_request", "request_id": "<uuid>", "tool_name": "Bash", "tool_args": "{\"command\":\"rm -rf ...\"}"}
tool_argsis the raw tool-call arguments JSON string (may be empty).
Extension → CLI, the user's decision:
{"type": "approval_response", "request_id": "<uuid>", "action": "approve"}
actionis"approve"or"reject". Any other value (including unknown future actions) is treated asreject, failing safe.
CLI → extension, when the request is no longer pending (answered in the panel or in the terminal) so the extension can clear its prompt:
{"type": "approval_resolved", "request_id": "<uuid>"}
- A
request_idthe extension does not recognize (already cleared, or never seen) MUST be ignored. Duplicateapproval_resolvedfor the same id is fine. - An
approval_responsefor an unknown/already-answeredrequest_idis ignored by the CLI.
Tool calls (extension → CLI)
The extension can invoke any of the CLI's tools (see docs/tools-reference.md)
through the bridge. The CLI routes each request through its normal tool
execution pipeline — the same permissions, allowlists, and approval flow as a
tool call made by the agent. The protocol stays generic: new capabilities need
no new frame types.
Extension → CLI:
{"type": "tool_request", "id": "<uuid>", "tool_name": "Bash", "tool_args": "{\"command\":\"gh api user\"}"}
CLI → extension, exactly one result per request id:
{"type": "tool_result", "id": "<uuid>", "success": true, "output": "...", "error": ""}
tool_name/tool_argsuse the same vocabulary asapproval_request:tool_argsis the raw tool-arguments JSON string.- The approval flow applies: an
approval_request(with its ownrequest_id) may precede the result; a denial produces{"success": false, "error": ...}— never a dropped id. success: false/error != ""means failure. ForBash,outputis the combined stdout/stderr; a non-zero exit setssuccess: falsewithoutputstill populated.- An unknown
tool_nameyields one failed result, not a closed socket. - The extension enforces its own timeout and MUST ignore a late
tool_resultwith an unknown id (same rule as staleapproval_resolved). If the socket drops before the result, the CLI drops it — no queuing or replay; the extension fails its pending calls on disconnect, and a reconnect does not resurrect old ids. - Primary consumer: the extension performs all GitHub API access as
Bashrequests runninggh api ...with the user's ownghauth. Allowlistinggh apiin the CLI's bash-allow configuration avoids an approval prompt per call.
Extension-side checklist
- WS client in the background service worker; port + token from the options page storage; reconnect with backoff.
tabs+scriptingpermissions and matching host permissions for the controlled tab.screenshotadditionally needsactiveTab/host permission forchrome.tabs.captureVisibleTab.readmust redact secret input values before returning (see thereadaction above);screenshotmust not click-to-reveal masked fields.- Known ceiling:
chrome.scripting-synthesized clicks/keys are untrusted events some sites ignore, and there is no coordinate-click action for the same reason; the upgrade path ischrome.debugger(CDPInput.dispatch*), which changes extension permissions, not this protocol.