Agent & CLI Reference
How an external AI agent (Claude Code, Codex, or any MCP client) drives SafeCoBrowser — the tools, the permission model, and the CLI. You act only within the mode the user grants, per tab, and they can revoke instantly.
Overview
SafeCoBrowser is a real, logged-in browser the human controls. Your agent is a guest that can see and act only inside the permission they grant — per tab, right now — and they can cut access instantly. You are the “brain”; SafeCoBrowser is the gated “body.”
- Every tab is Off by default — until a mode is granted, every call returns
permission_denied. - You cannot change the permission mode — only the user’s UI can.
- You act on the active tab; to work on another, bring it to the front with
switch_tabfirst (and you can never open or close tabs). One exception in the next release: selectorclick/fillwith Real input on, in the Electron/Linux build — see Real input under Agent tips. - You never get cookies, passwords, or the profile — only brokered tools.
- Every call (allowed or denied) is written to a tamper-evident audit log.
Connect your agent (MCP)
On launch SafeCoBrowser starts a localhost server (bound to 127.0.0.1 only) and writes its endpoint + bearer token to ~/.safecobrowser/endpoint.json. The token is required even on localhost; the server also validates Host/Origin.
~/.safecobrowser/endpoint.json
{ "url": "http://127.0.0.1:8676", "token": "<bearer>" }The MCP endpoint is POST {url}/mcp (Streamable HTTP). Register it with Claude Code:
TOKEN=$(node -pe "JSON.parse(require('fs').readFileSync(require('os').homedir()+'/.safecobrowser/endpoint.json')).token")
claude mcp add --transport http safecobrowser http://127.0.0.1:8676/mcp \
--header "Authorization: Bearer $TOKEN"The server name is safecobrowser; it exposes the tools below. Calls are gated by the tab’s mode, and effectful / run_js calls require approval.
Driving it from another computer. The server is loopback-only by default. To connect a remote agent, either tunnel the port (ssh -N -L 8676:127.0.0.1:8676 you@host, or Tailscale — recommended, encrypted) and point at 127.0.0.1 as usual, or enable Settings → Agent connection → “Allow LAN connections” (default off). That rebinds the server to your LAN and shows a LAN MCP URL to use in place of 127.0.0.1. LAN mode is plaintext HTTP — use it only on a trusted network; for the internet keep it off and use the tunnel. The bearer token, the Host/Origin allowlist, and the per-tab AI block all still apply.
CLI
The bundled safecobrowser CLI is a thin client over the same broker — handy for scripting and testing.
safecobrowser status # session status (+ the active tab id)
safecobrowser tools # list tools with their mode / risk / approval
safecobrowser invoke <tool> [json] # invoke a tool through the broker
safecobrowser audit [n] # last n audit records (default 20)
safecobrowser audit verify # verify the audit log's hash chainstatus/tools/invoke need SafeCoBrowser running; audit reads the local log and works offline.invoke exits 0 on success, 2 on a denial. Note: the CLI cannot raise the mode either.
Permission modes
An ordered ladder, set per tab by the user. A tool runs only if the tab’s current mode is at least the tool’s minimum.
| Mode | Unlocks (cumulative) |
|---|---|
blocked | Nothing — AI is off (default) |
read | read_page, screenshot, locate, read_screen_text, list_recipes, get_recipe |
inspect | + inspect_element, read_console, read_network, read_network_body |
act | + navigate, click, fill, scroll_to, and the coordinate tools move_to / click_at / scroll / press_key / type_text (all behind approval) |
develop | + run_js (full page control) |
Read the active tab’s current mode with get_mode (or safecobrowser status) instead of guessing from denials — it’s read-only, you still can’t change it. A few tools sit off this ladder and work at any mode (including Off): get_mode, list_tabs, switch_tab, and submit_feedback — they aren’t page operations.
Tools
Inputs are validated by the broker; a bad shape returns invalid_input.
| Tool | Mode · risk · approval | Input → Output |
|---|---|---|
get_mode | any · low · no | none → { tab, mode } (read-only; off the ladder) |
list_tabs | any · low · no | none → { tabs[{ tab, active, mode, title, url }] } (titles/URLs privacy-filtered; off the ladder) |
switch_tab | any · low · no | { tab } → { switched, tab?, reason? } — brings a tab to the front; later calls target it. Never changes a tab’s mode. |
read_page | read · low · no | none → { url, title, text, links[] } |
screenshot | read · low · no | none → { mimeType, base64 } (PNG) |
locate | read · low · no | { text?, selector? } → { count, matches[{ matched, tag, x, y, rect, inViewport, obscured }] } — coordinates from the DOM (no screenshot/vision); feed x,y to click_at |
read_screen_text | read · low · no | none → { count, words[{ text, x, y, rect, confidence }], note? } — offline OCR of the visible page into words + their CSS-viewport coordinates; feed x,y to click_at on canvas/no-DOM pages where locate finds nothing. Not audited. |
list_recipes | read · low · no | none → { domain, recipes[{ name, description?, steps }] } |
get_recipe | read · low · no | { name } → { name, domain, description?, steps[…] } (sensitive values withheld) |
inspect_element | inspect · low · no | { selector } → { matched, tagName?, attributes?, text?, outerHTML?, rect? } |
read_console | inspect · low · no | { limit? } (≤1000) → [{ level, text, ts }] — level ∈ log|info|warning|error; filter level:"error" to check a page for JS errors / uncaught exceptions |
read_network | inspect · low · no | { limit? } (≤1000) → [{ method, url, status?, ts }] |
read_network_body | inspect · low · no | { limit? } (≤1000; last 50/page) → [{ method, url, status, contentType, body, truncated, ts }] — XHR/fetch response bodies, any origin, text/JSON only, ~64KB cap; only captures after the current grant (cleared on grant/Stop AI), never audited |
navigate | act · medium · YES | { url } → { ok, url, title?, note? } — loads a URL (http/https only); returns the COMMITTED url after redirects so you can tell when a site bounced you to a login. Without it, reaching a new page needed run_js (the Developer grant). |
click | act · medium · YES | { selector } → { clicked, matched, realInput?, note? } |
fill | act · medium · YES | { selector, value } → { filled, matched, note?, realInput? } — honest: reads the field back, returns filled:false (+note) if it didn’t land |
scroll_to | act · low · YES | { text?, selector? } → { found, matched?, x?, y?, inViewport?, obscured? } — scrolls a match into view; returns its settled centre for click_at |
move_to / click_at | act · medium · YES | { x, y[, button] } → { done, realInput, note? } — real trusted input at a viewport coordinate (computer use). x and y are CSS-viewport pixels |
scroll (coord) | act · medium · YES | { x, y, dy, dx? } → { done, realInput } — wheel-scroll at a point; positive dy scrolls down. x, y, dy and dx are CSS-viewport pixels |
press_key | act · medium · YES | { key } → { done, realInput } — one allowlisted key (Enter/Tab/Esc/arrows/…); no modifier chords |
type_text | act · medium · YES | { text } → { done, realInput } — types at the focused field; audited as a char count, never the text |
run_js | develop · HIGH · YES | { script } (≤100k chars) → script return value |
submit_feedback | any · medium · YES | { message, email? } → { ok } — off the ladder; approval-gated; never sends page content |
- Coordinate arguments —
x,y(anddy,dxforscroll) are CSS-viewport pixels, given as numbers. Plain numeric strings such as"221"are also accepted; anything else ("abc","1e3") is rejected withinvalid_input. Numeric strings are accepted as of 1.2 (Mac App Store). The Electron/Linux download is an older build and still rejects them withexpected x to be a finite number— fixed in its next release — which is what an MCP client that does not send typed arguments will hit on every call there. Wherever you can, send real JSON numbers. fillnever logs the value (it may be a password) — only the selector (#email (value hidden)). Filling does not submit; request the submit as a separateclick.run_jsis full page control — its own grant, the script is shown in the approval card, and every script is logged. Prefer the narrower tools where they suffice.screenshotbase64 is large — save it to a file, don’t echo it.list_recipes/get_reciperead the user’s saved recipes (recorded how-to tutorials) for the current site — read one, then reproduce it withclick/fill. Scoped to the active tab’s domain; sensitive field values are withheld.- Prefer
locateover screenshots for finding things. On a real DOM,locatereturns an element’s coordinates straight from the page layout by text/selector (fast, no vision pass), withinViewport+obscuredflags. Pattern:locate→ pick a match withinViewport:true, obscured:false→click_at. If the match isinViewport:false, callscroll_tofirst (it scrolls the element in and returns its settled coordinates). It’s read-only — it never scrolls or changes the page. - Coordinate ("computer use") tools —
move_to,click_at,scroll,press_key,type_text— deliver real trusted input at a viewport point, for canvas/WebGL apps and pages with no usable DOM. Coordinates are CSS viewport pixels; the approval card shows a screenshot with a crosshair on your target. Prefer the selector tools (click/fill) orlocate+click_atwhen the DOM is usable — a coordinate is a poor approval prompt. Not a stealth feature: real input only, no humanized motion/timing. read_screen_textislocatefor pixels. When the page has no usable DOM — a canvas/WebGL app, or a page that’s a single image —locatefinds nothing.read_screen_textOCRs the visible page (offline, on-device) into words with their CSS-viewport coordinates, so you get a target forclick_at/type_textwithout a screenshot + vision pass. It’s not AI vision (you already see thescreenshot) — it’s fast, precise text→coordinate extraction. Read-only, no approval, and the recognized text is never written to the audit. On a real DOM, preferlocate.list_tabs/switch_tablet you see the open tabs and bring one to the front (your next calls then target it). Switching is not escalation — it never changes a tab’s mode, so a tab that’s Off still exposes nothing; callget_modeafter switching. Both are governed by the user’s Allow agent tab control setting (default on); if it’s off,list_tabsshows only the active tab andswitch_tabis denied.
Approvals & auto-approve
Every effectful tool requires approval — click, fill, scroll_to, the coordinate tools (move_to/click_at/scroll/press_key/type_text), and run_js. You invoke the tool, SafeCoBrowser shows the user an approval card with the concrete effect (the selector, the query for scroll_to, a screenshot + crosshair for the coordinate tools, or the full run_js script), and your call blocks until they answer.
- Approve → the tool runs, you get
{ ok: true, … }. - Reject →
approval_rejected. No answer in ~120s →approval_required. - The user may enable per-tab auto-approve (one toggle for click/fill, a separate one for run_js) — approved calls then run without a card, but are still logged. Don’t rely on it being on.
- Real input (you don’t control this): each tab has a user-set Real input toggle. When it’s on, your
click/fillare delivered as real, trusted input (the cursor actually moves and clicks; text is typed key by key) instead of synthesized events — which some sites require. You can’t read or set it and it never changes your mode; it only changes how the action is delivered. Your result carriesrealInput: true|falseso you know which path ran, and the audit log tags those actions[real input]. Ifclickreportsclicked:falsewith anotelike target obscured, an overlay was covering the point — it will not click the wrong thing; clear it and retry. It is not a stealth/anti-detection feature (no humanized motion or timing) — if a site still blocks real, trusted input, ask the user rather than trying to evade.In the next release: with Real input on for a tab,clickandfillnever silently fall back to a synthetic JS event. If real input can’t be delivered they refuse, and thenotesays why and that real input is on — for example “… not clicked (real input is on, so no JS fallback)”. Reasons include: the field is obscured, focus wasn’t confirmed, the typing didn’t land, and (macOS) SafeCoBrowser isn’t the frontmost app or the tab isn’t the active tab.type_textandpress_keyalready refuse in these cases. Only the user can turn Real input off for a tab (some editors reject keystrokes); thenclick/filluse the normal path and the result showsrealInput:false.- macOS: real input needs SafeCoBrowser to be the active app and the tab to be the active tab — a WebKit limitation, not a window-order one.
- Electron/Linux: selector
clickandfillwith real input also work on a background tab. The tab’s own grant is the only gate, and the approval card names the tab. - Both builds: the coordinate tools (
move_to,click_at,scroll,press_key,type_text) stay active-tab-only, and so do the orange click outline and replay frames.
What the human sees
Assume the user is watching. In the Mac App Store app (1.1+), every click and fill briefly outlines its target in orange, drawn by the app over the page — it does not appear in your screenshots or page reads, and you cannot suppress it. In 1.2 and later the user can also replay your session afterwards, frame by frame, from Activity → Replays.
Each effectful step you take (click, fill, click_at, move_to, scroll, scroll_to, press_key, type_text, navigate, run_js) is recorded with up to two frames: one at the moment of the action with the target outlined, and one with the result once the page settles. Replays are watch-only — nothing is re-run. Only allowed actions are recorded: never reads, never denied calls, and nothing from the period before a tab was granted. The privacy filter applies to the frames. Recordings stay on the user’s Mac, are kept for 7 days or the newest 50 sessions, and can be turned off or deleted in Settings. The Electron/Linux download is an older build and does not include Replays or the click outline yet.
There is no tool to read replays. The tool list above is unchanged — do not look for one. Recordings are for the human only.
Settings only the human controls
Two Settings affect how pages load for you. Neither is something you can read or change from an agent tool, and both are described here so you can tell what you are seeing. Both are new in the next release.
Network proxy — Settings → Network proxy
- One global proxy for every tab and container: HTTP, HTTPS or SOCKS5, host and port, with an optional username and password.
- Human-only: no agent tool can read or set it.
- The password is stored in the Keychain (macOS) or OS-encrypted storage (Electron), never in the settings file.
localhostis never proxied, so the local MCP server is unaffected.- It fails closed. If the proxy is unreachable, pages fail to load; they do not go direct. If every page suddenly errors, that is a likely cause — tell the user rather than retrying.
- macOS: a change takes effect after restarting the app (Settings says so). Electron: it applies immediately and reloads open tabs.
- Electron only: SOCKS5 with a username and password is not supported, because Chromium cannot authenticate to SOCKS5. Use HTTP/HTTPS, or unauthenticated SOCKS5. Settings refuses to save that combination.
- While a proxy is on, WebRTC is disabled or limited to the proxy, so a page cannot learn the real IP address over STUN. As a consequence, video calls and other WebRTC sites will not work with a proxy on.
- The proxy operator can see which sites are connected to, though not the contents of HTTPS pages.
- It is not anonymity, not a VPN, and not a way past bot detection.
User-Agent — Settings → User-Agent
Leave it on Default. Each build presents as the browser it really is: Safari on macOS (the next release adds the Version/x Safari/605.1.15 part that a bare embedded web view leaves out), and plain Chromium on Electron/Linux. Entering a User-Agent that claims a different browser from the engine underneath can make Cloudflare-style bot checks fail or loop — a Chrome string on the macOS WebKit build looped, while the honest Safari string passed.
Results & errors
Every invocation returns an InvokeResult:
{ ok: true, output: <tool output> } // success
{ ok: false, reason: <DenyReason>, message? } // denial / failureAll refusals fail closed. The reasons:
| reason | Meaning / what to do |
|---|---|
permission_denied | Tab mode below the tool’s minimum (incl. AI off). State the mode you need; don’t retry blindly. |
invalid_input | Input failed the schema. Fix the shape and retry. |
approval_required | Needed approval, none granted (incl. ~120s timeout). |
approval_rejected | The user said no. Stop — don’t retry the same action. |
revoked | Grant pulled mid-flight (Stop AI / mode change / tab closed). Re-confirm before continuing. |
timeout | Handler exceeded its budget. Retry once. |
handler_error | The tool threw. Report the message. |
unknown_tool | No such tool — check the name against safecobrowser tools. |
Privacy filter
The user can turn on a privacy filter — their own list of sensitive strings (name, address, account numbers) that SafeCoBrowser replaces with labels like [Name] or[Account]. There is no tool for it and the agent can’t see or change it.
- What you read may be redacted.
read_page,inspect_element,screenshot, and evenrun_jsread the rendered DOM, which is where redaction happens — so you’ll see[Name]where the real value was. That’s intentional. - Don’t try to defeat it. Treat a label as a value you’re not meant to have; if you genuinely need it, ask the user.
- Best-effort, not total. It redacts visible text nodes only — not form-field values, attributes, or network metadata — so a real value may still surface; treat anything sensitive accordingly.
Audit log
Every brokered decision is appended to ~/.safecobrowser/audit-log.jsonl, hash-chained for tamper-evidence, surviving restart, and viewable live in the in-app Activity panel.
safecobrowser audit 20 # recent records
safecobrowser audit verify # re-walk the hash chain (detects edits/deletes/reorder)click/inspect_element log their selector; run_js logs the full script; fill logs the selector but never the value; switch_tab logs the target tab id.
Agent tips
- Look before you leap: use
read_page/inspect_elementto find real selectors beforeclick/fill. - Diagnose with Inspect: combine
read_console+read_network(look for non-2xx) before proposing a fix; useread_network_bodyto read the actual XHR/fetch payload (e.g. the JSON behind a feed) — it only captures responses received after AI was granted. - Fill then submit, deliberately:
fillnever submits — request the submit as its own approvedclick. - Re-read after navigation: a click that navigates makes your prior DOM knowledge stale.
- Respect denials:
approval_rejectedmeans “no” — pick a different approach.permission_deniedmeans “raise the mode,” which only the user can do. - Work across tabs deliberately:
list_tabsto find the tab →switch_tabto it →get_mode(a freshly-switched tab is often Off — ask the user to raise it) → then read/act. Don’t hammer a tab you just switched to. - Minimize
run_js: prefer the narrower tools; keep any script tight and readable (the user reads it in the card). - Follow a recipe’s intent, not just its rows: if
get_recipe’s description states a pattern (“click each row’s download button, incrementing the index until none match”), read the live page and apply it to all items — there may be more than were recorded.
# typical loop (CLI form; MCP tool calls are 1:1)
safecobrowser status
safecobrowser invoke read_page
safecobrowser invoke inspect_element '{"selector":"form button[type=\"submit\"]"}'
safecobrowser invoke fill '{"selector":"#email","value":"[email protected]"}'
safecobrowser invoke click '{"selector":"button[type=\"submit\"]"}'