Reference
Install, and every option
How to get anoa onto each platform, and the complete command line — every flag the browser takes, every command you can send it.
The binary carries this reference too. anoa help prints it grouped,
anoa help <group> prints one section, and
anoa skills get commands prints the whole thing in a form meant to be
pasted into an agent's context rather than read.
macOS — Homebrew
One universal build serves both Intel and Apple Silicon.
$ brew tap porcupine-md/tap $ brew trust porcupine-md/tap $ brew install --cask anoa
It installs a single anoa shim on your
PATH. That one executable is the whole product — the terminal viewer is
its terminal subcommand, not a separate file. The app is Developer ID
signed and notarized, so it opens with no Gatekeeper warnings.
Upgrading
brew update first, or the tap will not have seen the newest release.
$ brew update $ brew upgrade --cask anoa
Linux
AppImage — recommended
One file. Download it, make it executable, run it. No unpacking, no install, no root.
$ chmod +x anoa-x86_64.AppImage # or anoa-aarch64.AppImage $ ./anoa-x86_64.AppImage --headless --port 9222 $ ./anoa-x86_64.AppImage open example.com
Built for x86_64 and
aarch64 against the same Qt, so both run on anything with
glibc 2.35 or newer — Ubuntu 22.04, Debian 12 and later, which is
what an ARM board usually runs. It carries its own FUSE, so it does not need the
libfuse2 package Ubuntu dropped in 22.04.
Install script — no root
Installs the portable bundle under your home directory and puts the launcher on
your PATH. Re-running it upgrades in place.
$ curl -fsSL https://raw.githubusercontent.com/porcupine-md/anoa-browser/master/scripts/install-linux.sh | bash
~/.local/lib/anoa/ the unpacked bundle — self-contained, nothing system-wide
~/.local/bin/anoa -> ../lib/anoa/anoa.sh
The symlink points at the launcher, never at the
raw binary: the launcher sets up the environment the bundled libraries need. It tells
you if ~/.local/bin is not on your PATH.
Homebrew on Linux
$ brew tap porcupine-md/tap $ brew install anoa
Nix
Turn flakes on first. They are still an experimental feature, so
a fresh Nix refuses every command below with "experimental Nix feature
'nix-command' is disabled". Once, in ~/.config/nix/nix.conf:
experimental-features = nix-command flakes
Or per command, changing nothing:
--extra-experimental-features "nix-command flakes".
Then this needs nothing else installed beforehand:
$ nix run github:porcupine-md/anoa-browser -- --headless --port 9222 $ nix run github:porcupine-md/anoa-browser -- open example.com
Or install it into a profile:
$ nix profile install github:porcupine-md/anoa-browser
In your own flake
# flake.nix inputs.anoa.url = "github:porcupine-md/anoa-browser"; # then, in a module or a devShell environment.systemPackages = [ inputs.anoa.packages.${system}.anoa ];
nix develop gives a shell with Qt, CMake, Node
and Python — everything the build and the test suites want.
Driving a remote box over SSH, note that a single-user Nix install puts
nix on your PATH through
~/.nix-profile/etc/profile.d/nix.sh, which a non-interactive
ssh host 'command' never sources. Use the full path,
~/.nix-profile/bin/nix, or source that script first.
Built for x86_64-linux, aarch64-linux and
aarch64-darwin. Intel Macs are missing because nixpkgs
does not build qt6.qtwebengine for x86_64-darwin — the
Homebrew cask is universal and covers them.
Windows
Download anoa-windows-x86_64.zip from the releases page, unzip it, and
run anoa.exe.
> anoa.exe --headless --port 9222 > anoa.exe open example.com
Everything works except anoa terminal. The terminal
viewer needs termios and select() on stdin, which have no MSVC
equivalent, so those sources are left out of the Windows build entirely and the
binary reports the unsupported platform if you ask for them.
Container
$ docker run -d --name anoa -p 9222:9222 ghcr.io/porcupine-md/anoa-browser $ docker exec anoa anoa open example.com $ docker exec anoa anoa snapshot -i $ docker exec anoa anoa click @e1
Publish 9224 as well if a CDP client outside
the container needs to attach — that is where the WebSocket ends up. See
the port layout.
From source
Prebuilt packages are above; building it yourself needs Qt 6.4 or newer with the WebEngine modules, and CMake. Prerequisites, targets, architecture and the release process are in docs/BUILDING.md.
$ cmake -B build -DCMAKE_BUILD_TYPE=Release $ cmake --build build -j
Starting a browser
The browser is the session. Every command in the reference below attaches to one that is already running and leaves it running, so the page, the cookies and the scroll position survive between them.
$ anoa --headless --port 9222 &
| Option | What it does |
|---|---|
-p, --port <n> | CDP port, default 9222. Also uses n+1 and n+2 — see the port layout |
--headless | No window, and no display server needed |
--no-sandbox | Disable the Chromium sandbox. Usually needed inside containers |
--width <px>--height <px> | Viewport size, default 1280×720 |
--profile <name> | A named profile with its own cookies and storage |
--profile-dir <dir> | Where profiles live |
--ephemeral | Keep nothing: no cookies, no storage, gone when the process ends |
--download-dir <dir> | Where downloads are saved. Default is the platform's Downloads folder |
--proxy <url> | host:port or scheme://host:port — http, https, socks5, socks4. Credentials go in the URL. See Going through a proxy |
--proxy-bypass <list> | Hosts that skip the proxy, comma separated |
--max-renderers <n> | Cap Chromium renderer processes. Chromium gives each tab its own, which is where the memory goes; fewer processes is less memory and less parallelism — see Memory with many tabs |
--extension <path> | Load an unpacked Chromium extension (manifest v2). Repeatable |
--auth-token <secret> | Require this bearer token on CDP and on the HTTP endpoints |
--embed-origin <origin> | Let this origin put the live view at /render in an iframe, and read the JSON endpoints. Repeatable; '*' allows any. Default is same-origin only |
--config <file> | A JSON or INI file of the options above |
-v, --version | Print the version and exit |
The live view forwards keystrokes, not just pixels. A page that
can frame it can watch a logged-in session and act inside it, which is why
--embed-origin exists and why the default refuses every origin but its
own.
Note too that --auth-token is off by default, and the
/render/* endpoints carry no origin check of their own. On a shared or
untrusted machine, set a token.
Reaching a running browser
Agent commands take these wherever they appear — before the verb or after it, both work.
| Option | What it does |
|---|---|
--port <n> | Where it is listening, default 9222 |
--host <h> | Which host, default 127.0.0.1 |
--token <secret> | Its bearer token, if it demands one |
--tab <id> | Which tab to act on. Default is the active one |
The port layout
One number gives three ports.
| Port | What listens there |
|---|---|
9222 | Discovery (/json, /json/version, /json/list) and the /render/* endpoints. This is what you point a client at |
9223 | Chromium's own debugging port, kept internal. It also serves the DevTools frontend |
9224 | The CDP proxy. The WebSocket a client is handed lands here — it multiplexes sessions and checks the token |
Sessions and profiles
Cookies and logins are kept between runs, in a profile named default
under your platform's application data directory. A browser that forgot every login
would not be one you could drive across separate commands.
# its own cookie jar and storage, kept on disk $ anoa --headless --profile work # keep profiles somewhere else $ anoa --headless --profile-dir /srv/anoa-profiles # keep nothing at all $ anoa --headless --ephemeral
Tabs share one jar unless asked otherwise, which is what
makes two logins to the same site possible at once — see
tab new --profile and --isolated below.
Going through a proxy
The proxy belongs to the browser, and every tab in it goes through it.
$ anoa --headless --port 9222 --proxy http://proxy.example:3128 $ anoa --headless --port 9222 --proxy socks5://127.0.0.1:1080 $ anoa --headless --port 9222 \ --proxy http://user:[email protected]:3128 \ --proxy-bypass "localhost,127.0.0.1,*.internal"
--proxy takes host:port or
scheme://host:port, where the scheme is http,
https, socks5 or socks4; a bare host gets
http://. Anything else is refused at startup rather than accepted and
then failing every request with a message that blames the site.
Credentials go in the URL. Chromium's own --proxy-server takes none,
so anoa passes it the origin and answers the proxy's authentication challenge itself
— without that a password-protected proxy simply hangs, and nothing in the page says
a password was wanted.
There is no per-tab proxy. Chromium keeps proxy settings per
process and Qt exposes no way to vary them per page — there is no proxy API on
QWebEngineProfile at all. Two proxies means two browsers on two ports,
addressed with --port.
Memory with many tabs
Chromium gives every tab its own renderer process, and that is where the memory goes. Measured on one machine, nine tabs against one, same page in each:
| Setting | Renderers | Renderer memory | Nine tabs computing at once |
|---|---|---|---|
| default | 9 | 1031 MB | 469 ms |
--max-renderers 4 | 4 | 468 MB | 1061 ms |
--max-renderers 3 | 3 | 355 MB | — |
--process-per-site (see below) | 1–2 | 130 MB | 2921 ms |
One tab on its own was 117 MB of renderer, so the growth is almost entirely per-tab renderer processes rather than anything in the browser process, which moved only 156 → 255 MB across the same range.
This is a trade, not a free win. Tabs that share a process share one main thread, so work that ran in parallel starts queueing — the last column is nine tabs each running the same CPU-bound loop, started at the same instant. Four processes for nine tabs cost 2.3× the wall time, which is the ratio you would predict.
It costs nothing when tabs are idle or driven one at a time, and a great deal when they all work at once. Pick the number from how your tabs behave, not from the memory column alone.
Going further
Chromium's own flags pass through, so the more aggressive setting is available
without a dedicated option. --process-per-site puts every tab of the same
site in one process:
$ QTWEBENGINE_CHROMIUM_FLAGS=--process-per-site anoa --headless --port 9222
Cross-site isolation survives it — tabs on different sites still get different processes — but same-site parallelism does not, which is the 2921 ms above.
Terminal viewer
anoa terminal renders the live page in your terminal and forwards your
clicks, scrolls and typing back to it. These options are CLI-only — they are never
read from a config file — and they are named apart from --port and
--auth-token so no flag changes meaning between modes.
| Option | What it does |
|---|---|
--term-host <host> | Host of the anoa to view, default 127.0.0.1 |
--term-port <n> | Port of the anoa to view, default 9222 |
--term-token <secret> | Bearer token for the viewed endpoint |
--cdp <url> | Attach to any external CDP endpoint instead — http://, https:// or ws:// |
--fps <n> | Refresh rate, 1–120, default 30 |
--gfx <mode> | auto · halfblock · iterm · kitty |
Given no target at all, anoa terminal attaches
to a browser already on 9222, and hosts its own only when there is none.
Tabs
$ anoa tab new [url] open a tab, print its id --name <name> call it something; --tab takes it after --profile <name> give it its own persistent cookies --isolated a throwaway jar, gone with the tab $ anoa tab list every tab; * marks the active one $ anoa tab select <id> make a tab the active one $ anoa tab close <id> close it (the last one cannot be closed)
--tab takes an id or a name, so
--tab search and --tab t2 can be the same tab. A name is an
alias; the id keeps working. Every other command takes --tab and acts on
the active tab without it, so anoa --tab t2 get text reads tab 2 while
tab 1 carries on with its own work.
Inspect
$ anoa snapshot page outline + interactive elements with refs $ anoa snapshot -i interactive elements only $ anoa find role <role> locate by role (button, link, textbox, …) $ anoa find text <text> by visible text, deepest match wins $ anoa find selector <css> by CSS, returned as refs --nth <n> keep only the nth match (1-based) $ anoa get text [<target>] visible text of the page or one element $ anoa get html <target> outer HTML $ anoa get value <target> current form value $ anoa get attr <target> <name> one attribute $ anoa eval <js> evaluate an expression in the page $ anoa status what the browser is attached to right now
<target> is a ref from a snapshot
(@e2) or any CSS selector. snapshot prints refs first,
because the ref is what the next command needs:
@e1 link Documentation
@e3 textbox Search [required]
@e7 button Sign in
Interact
$ anoa click <target> click, hit-tested — fails if something covers it $ anoa fill <target> <text> set a field's value, fire input/change $ anoa type <text> type into whatever has focus $ anoa upload <target> <file...> put files into a file input $ anoa press <key> Enter, Tab, Escape, ArrowDown, … $ anoa scroll [--up] [--by <px>] scroll the page $ anoa scroll --top | --bottom jump to either end $ anoa mouse move <x> <y> move the pointer $ anoa mouse down | up [x] [y] press or release, for drags $ anoa mouse wheel <dy> [x] [y] wheel at a position
Clicks are hit-tested. A button underneath a consent banner is reported rather than clicked through, so you find out the click did not land instead of finding out later.
Batches — many commands, one connection
Every command is its own process and its own connection, about 130 ms of it before
the page does anything. exec pays that once for a whole batch.
$ printf 'open example.com\nwait --selector h1\nget text h1\n' | anoa exec - $ anoa exec steps.txt
| Measured, twenty commands | Time |
|---|---|
| as one batch | 0.16 s |
| as twenty processes | 2.71 s |
Blank lines and # comments are skipped, quotes
group arguments, and the batch stops at the first command that fails, naming the line
— step five almost always depends on step four, and running the rest produces errors
that point at the wrong place.
One batch drives one tab. --tab goes on
exec itself; a --tab on a line inside is refused rather
than ignored, because honouring it would mean re-attaching — the cost this command
exists to avoid.
Downloads
A download is browser state, so eval cannot see it. Ask the browser.
$ anoa click @e4 something that downloads a file $ anoa wait --download $ anoa downloads state, path, bytes completed /home/you/Downloads/report.pdf (284120/284120 bytes)
--download-dir at startup chooses where files
land. The path downloads reports is where the bytes actually went, which
is not always the name the page asked for: a collision makes the browser rename.
Add --json for the same list as an array, with
url, path, state, received and
total.
Capture
$ anoa screenshot [file] PNG of the viewport (default screenshot.png) $ anoa pdf [file] PDF of the page (default page.pdf)
State — cookies, storage and emulation
$ anoa cookies list cookies $ anoa cookies set <name> <value> set one, scoped to the current page --url <url> scope it somewhere else instead $ anoa cookies clear clear them all $ anoa storage local everything in localStorage $ anoa storage local <key> one key $ anoa storage local set <k> <v> write one $ anoa storage local remove <k> delete one $ anoa storage local clear empty it $ anoa storage session ... the same, for sessionStorage $ anoa set viewport <w> <h> [scale] resize the page $ anoa set device [name] a preset; no name lists them $ anoa set geo <lat> <lng> override geolocation $ anoa set offline [on|off] cut the page off from the network $ anoa set headers '<json>' extra HTTP headers on every request $ anoa set media dark|light emulate prefers-color-scheme
An override belongs to the tab, not to the command that applied
it. A later screenshot, pdf or eval — a
separate process — sees it, which is what makes a dark-mode screenshot one command
rather than a batch.
Each tab keeps its own, a new tab starts clean, and stopping the browser forgets them all. It cuts both ways: a tab left offline stays offline until something sets it back.
Debug — what the page did
$ anoa console [--level <lvl>] console output, newest last $ anoa errors uncaught exceptions and rejections $ anoa network every request: kind or method, status, ms --clear forget what has been recorded so far
These are recorded inside the page, so they cover what happened before the command ran — a one-shot process could never have subscribed in time.
The buffer starts empty on every page load and holds the last 500 entries.
network covers the document, scripts, stylesheets, images and iframes
as well as fetch and XHR. The first column is the method where one is
known and the kind of load where none is — the browser records no verb for a
stylesheet. A status of - means the response did not disclose one, which
a cross-origin resource without Timing-Allow-Origin never does.
Agents
$ anoa skills list what skill documents this binary carries $ anoa skills get core the core workflow, for an agent to read $ anoa skills get commands every command, with its arguments $ anoa close stop the browser; returns once it is gone
The skill documents are printed straight to stdout, meant to be pasted into a context window rather than read on a screen.
Output and exit codes
Add --json to any command for machine-readable output.
| Code | Meaning |
|---|---|
0 | OK |
1 | The command failed |
2 | Usage error |
3 | No browser listening |
anoa help <group> prints one section on its
own: browser, navigate, inspect,
interact, state, debug, capture,
agents.
The HTTP endpoints — screenshots, the MJPEG stream, input injection and the live view — are documented in the README.
Playwright, Puppeteer, and anything else that speaks CDP
There is no driver to install and no adapter to write. anoa serves the Chrome discovery endpoints and a WebSocket a CDP client connects to exactly as it would to Chrome.
// Playwright const browser = await chromium.connectOverCDP('http://localhost:9222'); // Puppeteer const browser = await puppeteer.connect({ browserURL: 'http://localhost:9222' });
Both suites run against every release. Named profiles with
isolated cookie jars, unpacked Chromium extensions (manifest v2) and
Page.printToPDF all work through the same connection.
With --auth-token set, pass it as a bearer token or as
?token= on the WebSocket URL.
Ports — one number gives three
| Port | What listens there |
|---|---|
9222 | Discovery (/json,
/json/version, /json/list) and the
/render/* endpoints. This is the one you point a client at. |
9223 | Chromium's own debugging port, kept internal. It also serves the DevTools frontend. |
9224 | The CDP proxy. The WebSocket a client is handed lands here; it multiplexes sessions and checks the bearer token. |
--port moves all three. Leave
N+1 and N+2 free — if something else holds the debugging
port, discovery answers with output a client cannot parse.
The Browser domain
Chromium rejects these outright — the browser they describe is one Qt owns and it
has no handle on it — so anoa answers them itself:
setDownloadBehavior, grantPermissions,
resetPermissions, getWindowForTarget,
setWindowBounds and close.
They do the real thing. The download directory changes and deny
refuses; the window resizes and the page sees the new viewport; a granted permission
is one navigator.permissions.query reports as granted.
Permissions are partial, and say so. QtWebEngine can express
geolocation, notifications, audioCapture and
videoCapture. The rest of CDP's list has no equivalent, so a request
naming one of the others is refused, naming it, and nothing is
granted — granting the half it understood and reporting failure for the rest would
leave a permission on that the caller believes is off.
resetPermissions needs Qt 6.8, where a profile first gained the
ability to enumerate what it has granted. Older builds report that limitation
rather than a success they cannot deliver.
Emulation set over CDP behaves the way it does from the CLI: an override belongs to the tab and outlives the connection that applied it, so a client that reconnects finds the page as it left it.
MCP
POST /mcp speaks the Model Context Protocol over Streamable HTTP, so
a client that cannot run a shell — Claude Desktop among them — drives this browser
with typed tools instead of a remembered command line.
// the client's config
{
"mcpServers": {
"anoa": { "url": "http://localhost:9222/mcp" }
}
}
Every verb on this page is a tool, named
browser_* and carrying a JSON Schema for its arguments. One JSON-RPC
message in, one JSON response out; the server pushes nothing, so GET /mcp
answers 405 rather than holding a stream open forever.
It is the same browser the CLI drives. A tab opened from a shell is
visible to a tool call, a ref from browser_snapshot resolves either way,
and an override from browser_set outlives the call that made it.
A failed tool is a result, not a protocol error. It comes back
with isError and the command's own message. JSON-RPC errors are
reserved for a call that could not be parsed — a model needs to read a failure in
order to act on it, and an error at the transport layer is one it cannot see.
One call at a time. Each occupies the connection, so a second is refused immediately rather than queued behind it.
Two guards. --auth-token covers it like every
other endpoint, as a bearer token or ?token=. And an Origin
that is not in --embed-origin is refused with 403: a page in
someone's browser can reach localhost through DNS rebinding, and a request carrying no
Origin at all — a real client, or curl — is the one that is allowed.