Skip to content

Browser automation

A small extension runs inside the browser you already use — Chrome, Brave or Edge — and agents drive that: your tabs, your sessions, your logged-in accounts. Nothing downloads Chromium, nothing launches a headless profile, and no remote-debugging port is opened, so an agent does not hit a login wall on the first useful page.

The agent sees a page as a text outline with a ref on every clickable thing, so this works with models that have no vision at all:

Page: Sign in · Example
URL: https://example.com/login
- navigation
- link "Home" [e1] href=/
- link "Pricing" [e2] href=/pricing
- main
- heading[1] "Sign in"
- form
- textbox "Email" [e3]
- textbox "Password" [e4] (password)
- checkbox "Remember me" [e5] (unchecked)
- button "Sign in" [e6]
- text "Forgot your password?"
- link "Reset it" [e7] href=/reset

It clicks e6, and the click is a real browser event — dispatched through the Chrome DevTools Protocol, not element.click() — so React, Vue and every other framework respond exactly as they do to your hand on the mouse.

The extension is plain MV3 JavaScript with no build step and no dependencies: the folder you load is the source. There is no Web Store listing — it is load-unpacked for now — but you do not need a checkout of anything. The packaged desktop app ships the folder inside the app bundle, and Settings ▸ Browser tells you where it is.

  1. Open Settings ▸ Browser. Copy the Extension folder path (in the desktop app, Reveal in Finder opens it directly).
  2. In Brave, open brave://extensions — Chrome: chrome://extensions, Edge: edge://extensions.
  3. Turn on Developer mode (top right).
  4. Load unpacked → pick that folder.

In a source checkout the folder is apps/browser-extension. Setting LATCHAI_BROWSER_EXTENSION to an absolute path overrides the lookup entirely, which is what you want if you are hacking on the extension while running the packaged app.

Chromium 116 or newer is required. Chromium only — no Firefox, no Safari.

The extension connects out to the daemon at ws://127.0.0.1:<port>/browser and proves itself with a pairing token. The socket is refused unless its peer is on loopback and its Origin is a chrome-extension:// one, so nothing on the network and no web page can take that slot — even before the token is checked.

  1. In Settings ▸ Browser, copy the pairing token. It is minted the first time you open the panel and stored at <latchHome>/browser-token.json.
  2. Click the LatchAI icon in the browser toolbar.
  3. Paste the token, check the port (7777 unless you changed LATCHAI_PORT), press Save.
  4. The popup’s pill turns green — connected to LatchAI on :7777 — and the status line in Settings ▸ Browser says which browser and extension version answered.

The panel re-reads the engine every three seconds, so you can alt-tab to brave://extensions and back and the answer is current.

Pill Meaning
connected to LatchAI on :<port> Paired and answering
connecting… Dialling; the extension retries with backoff from 1 s up to 30 s
disconnected The daemon is not running, or not on that port
bad token The daemon closed the socket (4001). Retrying stops until you paste a new token
replaced by another browser Another browser took the connection (4003) — LatchAI drives one browser at a time

Rotate token in Settings ▸ Browser mints a new one and drops the live connection immediately; the extension parks on bad token until you paste the new value. That is also how you unpair — that, or removing the extension.

Thirteen built-ins, listed in the tool picker under the Browser family. Every page tool’s tabId is optional and defaults to the tab this run last used; with no such tab the tool answers no tab — call browser_navigate first rather than guessing at your foreground tab.

Tool Arguments What it does
browser_tabs Lists open tabs with their ids. A tab on a blocked address shows as [private/local page] with no URL or title; a tab in the LatchAI group is marked [latch]
browser_navigate url, tabId? Opens a URL and waits for it to load. Without tabId it opens a new tab, which becomes this run’s current tab. Asks permission per host
browser_snapshot tabId?, interactiveOnly? The ref-annotated page outline above. interactiveOnly drops text, heading and image lines
browser_click ref, tabId?, button? (left/right), double? Clicks the element a snapshot minted ref for
browser_type text, ref?, tabId?, clear?, submit? Types into a field. clear replaces the existing value, submit presses Enter afterwards
browser_press key, tabId?, modifiers? (Shift/Control/Alt/Meta) Presses one DOM key — Enter, Tab, Escape, ArrowDown, a character — for keyboard-driven UI
browser_select ref, value, tabId? Chooses a <select> option by value or visible label; input and change events fire
browser_scroll tabId?, dy?, ref? Scrolls by pixels (negative is up, default one viewport) or brings ref into view
browser_screenshot tabId? A JPEG of the tab, returned as an image in the tool result. Only useful to vision-capable models
browser_evaluate expression, tabId? Runs one JavaScript expression in the page and returns its value. Asks permission every time
browser_back tabId? One entry back in that tab’s history
browser_wait tabId?, ms?, text? Sleeps (up to 10 s) or polls until text appears in the page’s visible text (up to 10 s)
browser_close tabId? Closes a tab

The working loop is snapshot → act by ref → snapshot again. Refs live in the extension for the page they were taken on, so anything that navigates or re-renders kills them; acting on a dead one comes back as stale ref e12 — take a new snapshot, which the model is expected to fix by taking one. A snapshot lists only visible nodes, clips each node’s text at 200 characters and the whole tree at 20,000, traverses open shadow roots, and lists iframes without descending into them.

browser_evaluate is the escape hatch for what an outline cannot express — a table’s cells, a computed style, state a framework keeps in memory. It runs through the same debugger connection, awaits a promise, and returns the value as JSON: undefined comes back as null, a function or DOM node as Chrome’s own description string, a value over 100,000 characters as [value too large: N chars — return less], and a thrown exception as Evaluate threw: … for the model to learn from.

Beneath every permission there is an address floor, and nothing lifts it — not an “always allow” rule, not a config key, not the unattended default. browser_navigate refuses to go to a blocked address, and browser_snapshot, browser_screenshot and browser_evaluate refuse to read a tab already sitting on one:

  • any non-http(s) scheme — file:, chrome:, brave:, chrome-extension:, about:
  • loopback: localhost, 127.0.0.0/8, 0.0.0.0, ::1, anything under .localhost
  • private ranges: 10/8, 172.16/12, 192.168/16, link-local 169.254/16 and fe80::/10, unique-local fc00::/7
  • .local, .internal, .home.arpa, and single-label hosts like router or nas

The refusal names the rule and tells the model not to retry. The check fails closed: a URL it cannot parse is blocked.

The reason is concrete. The daemon’s own API is on loopback, and a page there would hand an agent a bearer key; a home network is a short hop from any browser. The same floor guards web_fetch and the URL form of load_skill, so nothing an agent can be talked into reaching by a page, a search result or a link in an email reaches your LAN.

Because it applies to reads as well, a tab on a blocked address is invisible: browser_tabs lists it as [private/local page], and no thumbnail of it is ever captured for the run log.

Above the floor sits the ordinary permission gate.

Opening a new site asks. browser_navigate gates on the host — the key is browser_navigate(example.com) — so approval is per site, not per URL. The card offers Allow once, Always allow, Deny once and Always deny, and “always” rules persist in <latchHome>/permissions.json, editable in Settings ▸ Permissions. In an unattended run (a cron fire, a webhook, a headless run) navigation allows without a prompt: the floor still bounds where it can go.

Running JavaScript asks every time. browser_evaluate gates on the tab’s current host and is ruled into the same class as run_shell, because arbitrary code in a page the model may have been steered to by untrusted content is arbitrary code. The prompt shows the expression itself, clipped and with secret-shaped strings redacted. Unattended, it falls to the unattended default rather than quietly allowing — that default ships as allow, and Settings ▸ Permissions is where you flip it. An “always allow” rule still silences it, per host.

Everything else allows. Clicking, typing, scrolling and reading were covered by the decision to let the agent onto that site at all. Flipping a chat turn to ⚡ Allow all tools releases the prompts for that turn as it does everywhere else.

Every acting call emits a browser.action event carrying the tool, the page it settled on, and a small JPEG of the result. Reads (browser_tabs, browser_snapshot, browser_screenshot) emit nothing — nothing changed. browser_evaluate does emit, because an expression can change the page.

  • Chat shows a row — 🌐 navigate example.com — Sign in — with the thumbnail under it, capped at 180px high. Click it to open the capture full size in the Lightbox.
  • The workbench event log and the run history transcript carry the same line.
  • The canvas node card shows the short form, 🌐 navigate example.com, as its detail.
  • The phone app renders the line without the thumbnail, and keeps browser_* tool rows as one-liners that do not expand: their arguments are refs and their results are page dumps, which bury a conversation on a small screen.

Two things happen in the browser itself, on purpose:

The LatchAI tab group. A tab the agent opens, and any tab it starts acting in, joins a blue tab group titled LatchAI in that window. Looking at a page — snapshot, screenshot, scroll, wait — never moves a tab: reading one of your tabs is not taking it over. So the group is exactly “the tabs the agent has its hands on”: collapse it to get it out of the way, close it to close all of them, drag a tab out to take it back. browser_tabs marks group members [latch] so the agent knows which mess is its own to clean up. Grouping is best-effort and never fails a command.

The yellow bar. While an agent acts, Chromium shows “LatchAI Browser is debugging this browser”. That bar is the visible cost of trusted input: the debugger is the only way an extension can synthesise events a page cannot tell apart from yours. It attaches on the first input, screenshot or evaluate per tab and detaches when the tab closes or the socket drops. Snapshots and text reads never attach it. Clicking Cancel on the bar detaches it too; the extension re-attaches on the next action.

Screenshots ride the model’s context, so only the last two image-bearing tool results keep their images on the wire. Older ones keep their text and gain [screenshot omitted — superseded] — a dozen page captures at 50–100 KB each would otherwise eat the window and the bill.

The browser tools are ordinary registry entries, so the ordinary allowlist governs them. An agent with no tools field gets everything, including the browser; name a list and it gets exactly that list:

---
name: release-checker
description: Verifies the docs site after a deploy.
tools: [browser_tabs, browser_navigate, browser_snapshot, browser_click, read_file]
---

The same applies to a workflow agent node’s config.tools. Granting browser_snapshot without browser_evaluate gives an agent that can read pages and click through them but never run code in one; leaving the family out entirely means it never sees the tools at all.

The extension holds one outbound WebSocket to the daemon; the daemon holds at most one extension. The first frame must be a hello carrying the token within 5 seconds, or the socket closes. A second browser that greets successfully replaces the first. The daemon pings every 20 seconds and the extension pongs — which is also what keeps the MV3 service worker from being suspended out from under a run. Each call has a deadline (30 seconds by default; browser_wait gets its own budget plus a margin), and a dropped socket rejects whatever was in flight with a plain sentence rather than hanging the run.

Reading a page (snapshots, text, refs) goes through chrome.scripting in an isolated world, which needs no debugger and leaves the page’s own globals alone. Input, screenshots and browser_evaluate go through chrome.debugger. The engine never sees a profile, a cookie or a debugging port — the extension is the only piece that touches the browser.

Two HTTP routes back the panel: GET /api/browser (status, token, extension folder) and POST /api/browser/token/rotate. Concurrent runs share the one browser; each run keeps its own idea of which tab it is on.

  • iframes are listed, not traversed. A snapshot shows iframe "title" (not traversed); the agent cannot reach inside one.
  • No file uploads, no downloads.
  • One browser at a time, one shared lease — parallel runs drive the same tabs.
  • Chromium only. No Firefox, no Safari.
  • Load-unpacked only. No Web Store build.

Every tool answers “Browser not connected”. The extension is not paired. Check the popup pill: disconnected means the daemon is not running or is on a different port (set the port in the popup); bad token means the token does not match.

bad token, and it stays there. The extension deliberately stops retrying after a rejected token — otherwise it would hammer the daemon forever. Copy the current token from Settings ▸ Browser, paste it into the popup and press Save. If you rotated the token, the old value is dead; there is no way to recover it, only to re-paste the new one.

replaced by another browser. Two browsers have the extension installed and paired. LatchAI drives one at a time; the last successful hello wins. Clear the token in the browser you do not want driven.

The status line says connected but the tools do nothing. Check the tab: the extension activates a tab before acting on it, so a command against a closed or moved tab fails. Have the agent call browser_tabs and start again with browser_navigate.

A refusal naming “not a reachable address”. That is the address floor, and it is not configurable — the page is on loopback, a private range, a .local name or a non-http(s) scheme. Nothing to change in settings; it needs a public address.

Actions started failing after an edit to the extension. Hit Reload on the extension card in brave://extensions. The service worker restarts and reconnects on its own.