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 · ExampleURL: 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=/resetIt 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.
Install the extension
Section titled “Install the extension”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.
- Open Settings ▸ Browser. Copy the Extension folder path (in the desktop app, Reveal in Finder opens it directly).
- In Brave, open
brave://extensions— Chrome:chrome://extensions, Edge:edge://extensions. - Turn on Developer mode (top right).
- 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.
Pair it
Section titled “Pair it”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.
- In Settings ▸ Browser, copy the pairing token. It is minted the first time you
open the panel and stored at
<latchHome>/browser-token.json. - Click the LatchAI icon in the browser toolbar.
- Paste the token, check the port (7777 unless you changed
LATCHAI_PORT), press Save. - 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.
The tools an agent gets
Section titled “The tools an agent gets”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.
Where an agent may go
Section titled “Where an agent may go”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-local169.254/16andfe80::/10, unique-localfc00::/7 .local,.internal,.home.arpa, and single-label hosts likerouterornas
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.
Permission prompts
Section titled “Permission prompts”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.
Watching it work
Section titled “Watching it work”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.
Restricting which agents can use it
Section titled “Restricting which agents can use it”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-checkerdescription: 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.
Under the hood
Section titled “Under the hood”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.
Limits
Section titled “Limits”- 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.
Troubleshooting
Section titled “Troubleshooting”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.