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.

Download the latest release →

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 &
OptionWhat it does
-p, --port <n>CDP port, default 9222. Also uses n+1 and n+2 — see the port layout
--headlessNo window, and no display server needed
--no-sandboxDisable 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
--ephemeralKeep 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, --versionPrint 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.

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

PortWhat listens there
9222Discovery (/json, /json/version, /json/list) and the /render/* endpoints. This is what you point a client at
9223Chromium's own debugging port, kept internal. It also serves the DevTools frontend
9224The 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:

SettingRenderersRenderer memoryNine tabs computing at once
default91031 MB469 ms
--max-renderers 44468 MB1061 ms
--max-renderers 33355 MB—
--process-per-site (see below)1–2130 MB2921 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.

OptionWhat 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 commandsTime
as one batch0.16 s
as twenty processes2.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.

CodeMeaning
0OK
1The command failed
2Usage error
3No 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

PortWhat listens there
9222Discovery (/json, /json/version, /json/list) and the /render/* endpoints. This is the one you point a client at.
9223Chromium's own debugging port, kept internal. It also serves the DevTools frontend.
9224The 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.