A browser eye and hands for a local AI agent, reachable with nothing but curl.
espai is a native messaging host and a Chromium extension. Together they put the user's
real browser — real tabs, real logged-in sessions, real DOM — behind a plain HTTP server on
127.0.0.1. An agent snapshots a page, clicks a thing, reads what came back.
Not hardened for a machine you do not trust. Any process running as you can drive your logged-in browser through it — see Security section.
- How you use it — Examples
- Install — Check it works
- What it does — The API · Finding the port · Why it works on real sites
- Chromium only
- Security
Install it once (below), and from then on there are two things to know.
Keep a browser window open. The extension holds the connection to the host, and the host is the HTTP server your agent talks to — so the server exists for exactly as long as a browser with the extension is running. Quit the browser and it goes with it. There is no daemon to start and nothing to leave running in a terminal.
Then say one sentence to whatever agent you already use. Put a clause in front of what you were going to ask for anyway:
Read
~/espai.toml, then whatever you wanted done in the browser — debugging, summarising, comparing, filling in, watching for a change.
The bold half is your actual request. The clause in front of it is the entire integration: that file tells the agent how to find the port, and the port serves a complete guide to everything it can do from there.
That works with anything that can run a shell command — Claude Code, Codex, Cursor, Gemini CLI, Copilot CLI — or a script you wrote yourself. Each one discovers what it can do at the moment it asks.
Read
~/espai.toml, then go through my GitHub timeline and give me the three new repos actually worth checking, keep them open in tabs.
Read
~/espai.toml, then search Amazon for a 4-slice toaster, open the top three, and build me a table of price, rating, review count and wattage — keep the tabs open in browser so I can see them.
Read
~/espai.toml, then open Polymarket and give me the ten biggest markets right now with their current odds and volume. Have two interesting open in browser.
If you do this often, move the clause into your agent's own instructions file — CLAUDE.md,
AGENTS.md, .cursorrules, a system prompt — and stop typing it:
When a task needs a real browser, read ~/espai.toml and follow what it says.
Linux and macOS, x86_64 and arm64:
curl -fsSL https://raw.githubusercontent.com/pouriya/espai/master/install.sh | shWindows, in PowerShell:
powershell -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/pouriya/espai/master/install.ps1 | iex"Either one downloads a release, puts the host binary in ~/espai/, and registers the native
messaging manifest for Chrome, Chromium, Edge, Brave, Opera, Vivaldi and Arc. How that
registration works differs: on Unix the manifest is copied into every browser profile root
that already exists, and roots that do not are skipped rather than created; on Windows
browsers read the manifest's path out of a registry key instead, so every key is written
whether or not the browser is there — a key for a browser you never install is inert. Arc on
Windows is the one gap: its registry key is undocumented, so it is registered on macOS only.
Then load the extension, which is the one step no script can do for you:
chrome://extensions → enable Developer mode → Load unpacked → ~/espai/extension
Two things to expect afterwards, both normal:
- Chrome warns about developer-mode extensions on every start. espai is not on the Web Store.
- While espai is driving a tab, Chrome shows a "espai started debugging this browser"
banner. That is
chrome.debugger, and it is also why DevTools and espai cannot be open on the same tab at once.
ESPAI_PREFIX moves where it installs, ESPAI_VERSION pins a version. To build from source
instead, or to uninstall, see CONTRIBUTING.md.
The host is lazy — Chrome spawns it when the extension's service worker connects, so open a page first. Then:
cat ~/espai.toml # one entry per running browser profile
curl -s http://127.0.0.1:$PORT/health # {"espai":true,...}
curl -s http://127.0.0.1:$PORT/ # the full API guideOn Windows, write curl.exe — bare curl is an alias for Invoke-WebRequest and takes none
of these flags. ~/espai.toml carries a PowerShell line for reading the port out of it.
The interesting part is what it doesn't need. No SDK, no client library, no language
bindings, no WebDriver, no daemon to configure, no MCP server to register, no API key. Any
agent that can shell out to curl — which is essentially all of them — gets the same full
browser:
PORT=$(grep -m1 '^port' ~/espai.toml | tr -dc 0-9)
curl -s http://127.0.0.1:$PORT/tabs # what is open
curl -s http://127.0.0.1:$PORT/tabs/42/snapshot # the page, as refs
curl -s -H 'Content-Type: application/json' -d '{"ref":17}' http://127.0.0.1:$PORT/tabs/42/click
curl -s -H 'Content-Type: application/json' -d '{"text":"Posted"}' http://127.0.0.1:$PORT/tabs/42/waitThat 17 is a ref — a number snapshot hands to every element worth acting on, used in
place of a CSS selector. No code is written on either side, and nothing is generated.
GET / serves a complete markdown guide, and so does every 404. An agent that guesses a
path wrong is handed the entire API in the error body and gets it right on the next call —
no human writes integration notes, and none go stale.
The surface it describes
GET / this guide
GET /health {"espai":true,...}, answered without the browser
GET /events?since=N&wait=30 long-poll the event log
GET /tabs every open tab
POST /tabs {"url":"...","active":true} → opens one
DELETE /tabs/{id} closes it
GET /tabs/{id} url, title, loading state, frames
GET /tabs/{id}/title title read from the live DOM
GET /tabs/{id}/snapshot the page as a ref-tagged outline
GET /tabs/{id}/text visible readable text
GET /tabs/{id}/html ?within=REF for one subtree
GET /tabs/{id}/query?sel=CSS matches with text, attributes, box, ref
GET /tabs/{id}/screenshot PNG bytes; ?full=1 for the whole page
GET /tabs/{id}/network captured requests; ?filter=SUBSTR &body=0:400
GET /tabs/{id}/console captured console lines
Screenshots are real PNG bytes and work below the fold and in background tabs. collect
scrolls an infinite feed, extracts fields from every match, and deduplicates until it hits a
limit or the bottom.
Everything below takes a JSON body and targets an element with {"ref":17} or
{"sel":"CSS"}:
POST /tabs/{id}/navigate {"url":"..."} | {"back":true} | {"forward":true} | {"reload":true}
POST /tabs/{id}/click {"ref":17,"button":"left","clicks":1,"modifiers":["Shift"]}
POST /tabs/{id}/type {"ref":17,"text":"hello","clear":true,"submit":true}
POST /tabs/{id}/key {"key":"Enter","modifiers":["Control"]}
POST /tabs/{id}/hover {"ref":17}
POST /tabs/{id}/scroll {"by":[0,800]} | {"to":[0,0]} | {"ref":17}
POST /tabs/{id}/select {"ref":17,"value":"uk"}
POST /tabs/{id}/wait {"sel":"CSS"} | {"text":"..."} | {"gone":"CSS"} | {"url":"SUBSTR"} | {"network_idle":true}
POST /tabs/{id}/collect {"sel":"article","fields":{"who":"a","body":"[lang]"},"max":100}
POST /tabs/{id}/eval {"js":"return document.title"}
Ports come from the OS at startup and are never fixed. Every running instance registers
itself in ~/espai.toml:
[[instances]]
pid = 9182
port = 43111
started_at = 1754241234
origin = "chrome-extension://aaaabbbbccccddddeeeeffffgggghhhh/"One entry per running browser profile. Two profiles open means two independent browsers with
different tabs — GET /tabs on each says which is which. Entries that no longer answer are
garbage-collected whenever an instance starts, so anything listed should respond.
The file carries a comment header, written whether or not there are any entries, so a file listing nothing still tells an agent what to check — which is precisely when there is no server left to ask.
Refs, not CSS selectors. App sites generate their class names, so a selector committed
today is broken next week. snapshot returns the page as an outline where every element
worth acting on carries a number, and you act on the number:
[1] link "Home" /home
[2] article
[3] link "@someone"
text "the post body here"
[4] button "Like" pressed=false
A ref is stable across snapshots, and a ref whose element is gone returns 410 — "the page moved on, snapshot again" — rather than a generic failure you might retry forever.
Trusted input. Clicks and keystrokes go through chrome.debugger and the browser's own
input pipeline, so isTrusted is true and the page cannot tell them from a human's. React
and most of what large sites are built on silently ignore events synthesised from script,
and the failure mode is a click that returns success and does nothing.
The network capture. Feeds and product listings arrive as JSON long before they become DOM, and reading that is easier and more complete than scraping the render. Every response is logged with its metadata; payloads stay in the browser until you ask for a window onto one, so peeking at 400 characters of a 300KB response costs 400 characters.
None of it is site-specific: generic primitives plus the agent's own prompt beat a selector file that rots.
Firefox support was built and then deliberately removed. It implements none of
chrome.debugger, so trusted input, screenshots, eval and the network capture are all
unavailable there — what is left is reads and navigation, which is just a worse way to fetch
a page. A Firefox eye would be a different project with a different mechanism, not a flag
here, and a patch reintroducing it will be declined.
espai drives the browser the user is logged into — anything it clicks, they clicked. There are two attackers and only one is answered.
A web page in the user's own browser — answered. Any page can POST to a loopback port, and a CORS simple request goes out without a preflight. Three checks, applied before the body is read, so a refused request has no effect at all:
- an
Originheader is 403. Browsers always send it cross-origin,curlnever does. This is the actual defence. - a
Hostthat is not127.0.0.1orlocalhostis 403, catching DNS rebinding. - a POST that is not
application/jsonis 415.
Each is covered by a test standing for a request a real page can really make.
Another process at the same uid — not answered. espai is not authenticated. Any
process running as you reads the port out of ~/espai.toml and calls in exactly like a
legitimate agent does. The checks above establish that a caller is not a web page; they
establish nothing about who it is.
A bearer token would not close this: the secret sits under the same uid the attacker already has. It is an OS boundary, not an HTTP one.
So there is no plan yet that makes espai safe against the machine it runs on. Treat the API as equivalent to a logged-in browser session, because that is exactly what it is.


