Skip to content

Repository files navigation

espai

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.

Contents

How you use it

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.

Examples

Read ~/espai.toml, then go through my GitHub timeline and give me the three new repos actually worth checking, keep them open in tabs.

Claude Code in VS Code reading a GitHub timeline, the three repos it picked open in Chrome


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.

Cursor comparing three Amazon toasters, the product pages open in Chrome


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.

Claude Code listing the ten largest Polymarket markets with volume and odds, two open in Chrome


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.

Install

Linux and macOS, x86_64 and arm64:

curl -fsSL https://raw.githubusercontent.com/pouriya/espai/master/install.sh | sh

Windows, 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 modeLoad 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.

Check it works

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 guide

On 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.

What it does

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/wait

That 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.

The API

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"}

Finding the port

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.

Why it works on real sites

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.

Chromium only

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.

Security

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 Origin header is 403. Browsers always send it cross-origin, curl never does. This is the actual defence.
  • a Host that is not 127.0.0.1 or localhost is 403, catching DNS rebinding.
  • a POST that is not application/json is 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.

About

A browser eye and hands for local AI agents. Snapshot a page, click a ref, read the JSON behind it — all with curl.

Topics

Resources

Contributing

Stars

9 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages