Drive a real browser from the command line and from MCP. Stable element refs, and four
libraries with no SwiftPM dependencies — the CLI is the one target that links one, the
official MCP SDK, and only for alohajet mcp --endpoint.
Tool reference · Threat model · Contributing
Playwright was built to script a browser you control. alohajet is built to hand a browser to a model: it attaches to a Chromium that already exists — one it launched, or one you were already using — and exposes a small set of verbs that can finish a task on a web page.
The reason to pick it over the alternatives is that element references are derived from
the page, not minted per snapshot. Competing tools hand out [ref=e1], [ref=e2] over
one snapshot and tell you to re-snapshot before you act. alohajet's ref is a hash of the
element's authored name or its frame-scoped xpath, so it is the same string in the next
process, against a different browser, tomorrow:
$ alohajet open https://example.com
Opened: https://example.com
Tab ID: 100389FC9132CC3E52AEAC2C4CFE76CB
...
[Learn more] {aloha-id="719a97a0" a}
$ alohajet click 719a97a0 # a separate process; nothing was carried over
Clicked element "719a97a0" (single). Navigated to https://www.iana.org/help/example-domains.That is the whole pitch, and it is proved below rather than asserted — same page, three different browsers, same ref. You can reproduce the proof yourself in about a minute; it needs Python 3 and nothing else.
Despite the name on every symbol, no Aloha software is involved on that path: the ref is
minted by a script this package injects over plain CDP, the default lane drives a Chromium
this package provisions itself, and BrowserTools does not link AgentDriver — so the tool
layer cannot reach an Aloha automation server even by accident.
What it is not, before you spend the minute: not a Playwright replacement — no
assertions, no test runner, no trace viewer. Not a general CDP console — a deliberately
small set against chrome-devtools-mcp's ~57. No coordinate clicking, so a <canvas>
game or a WebGL viewport is unreachable. No extraction verb, no readability pass. Every one
of those is expanded, with the reproduction, under Limitations.
| Swift | 6.2 or newer |
| OS | macOS 14+. Everything on this page was run on macOS 26.2 (arm64), Swift 6.2.3, Google Chrome 152.0.7977.77. |
| Linux | builds and tests in CI (Ubuntu 24.04, Swift 6.2.3); every measurement on this page is from macOS. The release tarball needs libcurl.so.4 — present wherever curl is, and apt-get install -y libcurl4 on a minimal image. |
| Browser | Google Chrome or Chromium — no minimum version is checked or established; everything here was run against 152.0.7977.77. With none installed, a 191 MB Chrome for Testing is downloaded on first use (Configuration). --cdp takes any CDP endpoint; --browser aloha takes the Aloha browser. |
| Python 3 | only to serve the fixture pages in The proof. |
| Dependencies | none in the four library products (BrowserTools, AgentDriver, CDP, ToolABI) — Foundation only, no vendored tree. One in the alohajet executable: the official MCP Swift SDK (from: "0.12.1"), which mcp --endpoint relays over and nothing else uses. A consumer linking the libraries resolves it and compiles none of it. |
git clone https://github.com/AlohaBrowser/alohajet-cli.git
cd alohajet-cli
swift build -c release --product alohajet
mkdir -p ~/.local/bin
install -m 755 .build/release/alohajet ~/.local/bin/alohajet # or anywhere on PATH$ swift build -c release --product alohajet
Build of product 'alohajet' complete! (19.94s)If ~/.local/bin is not on your PATH, every command on this page answers command not found. Check the install landed — this is the only command that touches no browser:
$ alohajet --help | head -1
alohajet — the AlohaJet agent and a scriptable browser, from the command line.alohajet --version prints the version. There is no doctor.
Prebuilt macOS and Linux tarballs are attached to every
release, with a SHA256SUMS
beside them. Nothing is signed or notarized, so provenance is what there is to check:
sha256sum -c SHA256SUMS
gh attestation verify alohajet-macos-universal.tar.gz --repo AlohaBrowser/alohajet-cliThe attestation is a Sigstore build-provenance statement binding each asset — and
SHA256SUMS itself — to the workflow, repository and commit that produced it.
To remove it: rm ~/.local/bin/alohajet, then rm -rf "$TMPDIR/alohajet-$(id -u)" and
rm -rf ~/Library/Application Support/AlohaJet — the latter only exists if it downloaded a
Chrome, and it is the 191 MB one. The throwaway $TMPDIR/alohajet-cdp-<uuid> profiles and
alohajet-chrome-stderr-*.log files reap themselves (reapStaleProfiles in
Sources/CDP/CDP.swift): a profile once the owning pid is provably gone, a log once it is
a day old.
As a library. Every library product is @MainActor-isolated by default, so a call from a
non-isolated context needs an await or an actor hop:
.package(url: "https://github.com/AlohaBrowser/alohajet-cli.git", exact: "0.4.4")
.product(name: "BrowserTools", package: "alohajet-cli")Each line is its own process. The browser is launched by the first command and outlives
every one of them until quit — that is deliberate, because refs printed by one command
have to still work in the next.
$ alohajet open https://example.com
Opened: https://example.com
Tab ID: 100389FC9132CC3E52AEAC2C4CFE76CB
This tab is now the one page_click, page_type, page_select, page_navigate, page_press_keys, page_wait_for and get_text address. The page below is a snapshot taken at open; nothing refreshes it for you. After any click, type or navigation, call manage_tabs read with this tab_id to see the current page. Pass use: false on open to skip taking it.
Tab: "Example Domain" (ID: "100389FC9132CC3E52AEAC2C4CFE76CB")
URL: https://example.com/
Viewport: 756x469
Interactive view: the page rendered as structural markdown — # headings, - list items, [text](href) links, | a | b | table rows, and plain paragraphs. Content is clean and id-free; every actionable element (link, button, input, select, landmark) carries a trailing {aloha-id="ID" tag} marker. ...
<untrusted_page_markdown K="BA0AFD9D">
<interactive_page_markdown>
# Example Domain
This domain is for use in documentation examples without needing permission. Avoid use in operations.
[Learn more] {aloha-id="719a97a0" a}
</interactive_page_markdown>
</untrusted_page_markdown K="BA0AFD9D">
$ alohajet click 719a97a0
Clicked element "719a97a0" (single). Navigated to https://www.iana.org/help/example-domains.
$ alohajet quit
Closed the shared browser on port 55697.Two things there are not decoration. The <untrusted_page_markdown K="BA0AFD9D"> wrapper is
a keyed fence around everything the page said, so a model can tell page text from
instructions; the key is fresh per read, so a page cannot close the fence and write outside
it. It is a delimiter, not a defence — nothing detects prompt injection. The paragraph
above it is the tool's own preamble to the model, sent on every read — verbose on purpose,
and the one thing this page shortens. Every cut is marked ...; nothing else is edited.
alohajet --help for the full text, alohajet <command> --help for one command.
| command | tool it calls |
|---|---|
open <url> |
manage_tabs open |
read [--tab <id>] |
manage_tabs read |
tabs |
manage_tabs list |
close <id> |
manage_tabs close |
click <ref> [--double|--right] |
page_click |
type <ref> <text> [--submit] [--no-replace] |
page_type |
select <ref> --text <t>|--index <n> |
page_select |
text <ref>[,<ref>...] [--max-chars <n>] |
get_text |
goto <url> / back |
page_navigate |
keys <chord> |
page_press_keys |
wait <css> [--timeout-ms <n>] |
page_wait_for |
upload <ref> <path>... |
page_upload |
quit |
— closes the shared browser |
mcp |
— serves all nine over stdio |
--tab <id> is global, not a flag on read: it goes before the verb on any command that
touches a page — alohajet --tab <id> click <ref>. The table names it only where you are
most likely to need it.
--json on any command prints the raw tool result instead of prose. Exit codes, each one
run to confirm it:
$ alohajet --launch open https://example.com ; echo $? # 0 ok
$ alohajet --launch open example.com ; echo $? # 1 tool error
$ alohajet frobnicate ; echo $? # 2 usage
$ alohajet --cdp 9999 read ; echo $? # 3 browser unreachableOne command is not a tool call. alohajet -p "<prompt>" hands one turn to an agent loop
that does not ship here: it POSTs to an HTTP endpoint, defaulting to
http://127.0.0.1:8765 — the Aloha browser's own automation server on this machine, which
is launched or activated for you. Without that browser the flag needs a server of your own
at --endpoint <url>, and the contract it has to speak is
docs/agent-endpoint.md. Launches that cannot work are refused by
name instead of by timeout:
$ alohajet -p "book me a table" --headless
alohajet: app already running with a visible window — quit it, or drop --headlessalohajet mcp speaks Model Context Protocol over stdio: newline-delimited JSON-RPC 2.0,
protocol versions 2024-11-05, 2025-03-26 and 2025-06-18. Only protocol traffic goes
to stdout; diagnostics go to stderr. No browser is launched until the first tools/call,
so a host that only lists tools pays nothing. That browser is the one the browser options
name — by default the SAME shared Chromium alohajet open uses, kept until alohajet quit, so a host and a shell see one browser and one set of tabs between them.
The block below goes wherever your host keeps mcpServers — for Claude Code that is
.mcp.json beside the project, and claude mcp list then answers
alohajet: /Users/you/.local/bin/alohajet mcp - ⏸ Pending approval. Use the absolute path;
the host does not run it through your login shell, so ~/.local/bin on your PATH does
not help it.
{
"mcpServers": {
"alohajet": {
"command": "/Users/you/.local/bin/alohajet",
"args": ["mcp"]
}
}
}To drive the browser you already have open instead of the shared one, add the connection flag — that config hands the agent every tab and every logged-in session in that browser:
{
"mcpServers": {
"alohajet": {
"command": "/Users/you/.local/bin/alohajet",
"args": ["mcp", "--cdp", "9222"]
}
}
}A live initialize answers serverInfo: {"name": "alohajet", "version": "0.4.4"}, and
tools/list returns the tools below with readOnlyHint=true on exactly two,
get_text and page_wait_for. The hint is per tool, not per call, so manage_tabs is
false even though its list and read actions only observe. manage_tabs with
include_screenshot: true returns a second content block of type image next to the text;
the CLI has no equivalent and drops the pixels.
With --endpoint, alohajet mcp serves nothing of its own and launches no browser. It is
a pipe: stdin/stdout on one side, POST <url>/mcp on the other — the MCP server a running
Aloha browser mounts on its automation port. The tools you get are that browser's, not the
nine above, so this lane needs that browser — or another host serving MCP over HTTP.
It exists for one client. Claude Desktop's config parser takes {command, args, env} and
drops any entry carrying type/url/headers, so a browser that already speaks MCP over
HTTP is unreachable from it without a stdio front end. Hosts that speak HTTP (Claude Code:
claude mcp add --transport http …) should talk to that endpoint directly — this lane adds
a process and buys them nothing.
{
"mcpServers": {
"aloha-browser": {
"command": "/Users/you/.local/bin/alohajet",
"args": ["mcp", "--endpoint", "http://127.0.0.1:8765"]
}
}
}Both halves use the official MCP SDK's own transports, because the server end validates
Accept: application/json, text/event-stream, answers over SSE and issues a session id
that has to be replayed as Mcp-Session-Id. The bearer token is read per run, never
pasted, and plaintext http off this machine is refused outright (exit 2) — the same rules
-p follows, written out in docs/agent-endpoint.md.
--browser chromium (the default) drives a Chromium. --browser aloha attaches to the
Aloha browser's own CDP listener on 127.0.0.1:9222 (or ALOHA_CDP_PORT), starting the
app if it is not running. It is the user's browser: never terminated, and its pre-existing
tabs are theirs.
$ alohajet --browser aloha tabs
2 tab(s) open:
1. [Current tab — the page you are looking at] [the user's tab — cannot be closed]
ID: 786347A9-F916-41CF-A23E-78B28AA03961
URL: [non-web URL hidden]
2. Error [the user's tab — cannot be closed]
ID: A3A286AD-D796-4FED-9AD7-895845AFB4EB
URL: http://localhost:6555/errors/error.html?...Within the chromium lane there are three connection modes:
| mode | what it does | whose browser |
|---|---|---|
| (default) | launches one headless Chromium on first use, records it in <tmp>/alohajet-<uid>/browser.json, and every later invocation attaches to it |
ours — close works on tabs it opened, quit ends it |
--launch |
a throwaway Chromium for this command only, terminated on exit. Refs it prints die with it, so single commands only | ours, briefly |
--cdp <ws-url|port|host:port> |
attaches to a browser already listening on a debugging port. Never launched, never terminated | the user's, and everything in it is reachable |
--headless / --no-headless and --port <n> are read only when a browser is actually
launched, not when one is reused. SIGINT and SIGTERM terminate a browser this process
launched and delete its throwaway profile; a --cdp or aloha browser is left alone.
Every actionable element in a read carries a ref:
input("query") {aloha-id="4d399647" input}
select "alpha" [options: [49699f13.0] *alpha, [49699f13.1] beta] {aloha-id="49699f13" select}
[Go] {aloha-id="6586acdd" button}
There is exactly one place a ref comes from — alohaIdFor in
Sources/BrowserTools/Runtime/DomTreeScript.swift.
It hashes a string built from two parts:
- The scope — which document the element lives in: the chain of iframe selectors and shadow-root indices down to it. Without it, an iframe's children and the top document's children collide, because both are walked from the same root path.
- The identity — the first of these that answers:
- an authored name the page's own developers wrote:
#id, else[data-testid]/[data-test]; - otherwise the element's xpath within that scope.
- an authored name the page's own developers wrote:
An authored name that looks framework-generated is refused and the xpath used instead — a
name renumbered on every remount would make the ref less stable than the position it
replaced. The predicate is one line of looksGenerated, and it is stricter than the
#ember1234 example suggests: a name is refused if it does not match ^[A-Za-z][\w-]*$,
or ends in three or more digits, or ends in -<digits> or _<digits>. So id="item-1",
id="row_2" and id="step-3" all fall back to xpath, silently.
The hash is 32-bit folded to hex; a collision within one walk gets a -2 suffix. The ref
is written back onto the element as an aloha-id attribute, which is how page_click and
friends find it again: document.querySelector('[aloha-id="4d399647"]').
Because the ref is derived, not allocated, nothing has to remember it. Same page, same element, same input string, same hash — for anyone, in any process.
Run it yourself. Two fixtures, one server, four commands; the ids you get back are the ids printed here, because they are a function of the markup and nothing else. In one terminal:
mkdir -p /tmp/refs && cd /tmp/refs
cat > rerender.html <<'EOF'
<!doctype html>
<meta charset="utf-8">
<title>Rerender</title>
<div id="panel">
<button id="save">Save</button>
<button>Cancel</button>
</div>
<script>
setTimeout(function () {
document.getElementById("panel").innerHTML =
'<button id="save">Save</button><button>Cancel</button>';
}, 300);
</script>
EOF
cat > insert.html <<'EOF'
<!doctype html>
<meta charset="utf-8">
<title>Insert</title>
<div id="panel">
<button id="save">Save</button>
<button>Cancel</button>
</div>
<script>
setTimeout(function () {
document.getElementById("panel").innerHTML =
'<button>Extra</button><button id="save">Save</button><button>Cancel</button>';
}, 300);
</script>
EOF
python3 -m http.server 8877 --bind 127.0.0.1rerender.html throws away both buttons 300ms after load and builds new ones. open sees
the originals; read, a separate process, sees the replacements. Both aloha-id lines
below are captured output — only the preamble paragraph is cut, marked ...:
$ alohajet open http://127.0.0.1:8877/rerender.html
Opened: http://127.0.0.1:8877/rerender.html
Tab ID: 75EA0FE245FFF2887BAFA7C5C81E8B7F
...
[Save] {aloha-id="2a441adc" button}
[Cancel] {aloha-id="a359a5c" button}
$ alohajet read
...
[Save] {aloha-id="2a441adc" button}
[Cancel] {aloha-id="a359a5c" button}Save keeps its ref because it has id="save". Cancel has no authored name, so its ref
is the hash of its xpath — and the replacement lands at the same xpath, so it survives too.
The next section shows what happens when it does not.
The same argument across browsers, which needs your own second Chrome
(--remote-debugging-port=9787) and, for the third line, the Aloha browser installed:
$ alohajet open https://example.com
[Learn more] {aloha-id="719a97a0" a}
$ alohajet --cdp 9787 open https://example.com
[Learn more] {aloha-id="719a97a0" a}
$ alohajet --browser aloha open https://example.com
[Learn more] {aloha-id="719a97a0" a}The guarantee is narrower than "forever", and the narrowness is what makes it believable.
insert.html, the second fixture above, re-renders the same panel with one extra button
before the other two:
$ alohajet open http://127.0.0.1:8877/insert.html
...
[Save] {aloha-id="2a441adc" button}
[Cancel] {aloha-id="a359a5c" button}
$ alohajet read
...
[Extra] {aloha-id="a359a3d" button}
[Save] {aloha-id="2a441adc" button}
[Cancel] {aloha-id="a359a7b" button}Save is unchanged — it has #save. Cancel went a359a5c → a359a7b, because its
xpath moved. That is the bound on the whole claim, and it is why the table below is worth
reading before you build a loop on top of this.
| what happens | the ref |
|---|---|
the page re-renders, element keeps its #id or [data-testid] |
survives |
| the page re-renders, element without an authored name stays in the same position | survives |
| an element without an authored name moves — a sibling inserted before it, a list reorders | changes |
the element's #id changes, or the page starts minting generated ids |
changes |
a navigation, reload, or goto |
the ref string is unchanged for the same page, but no ref resolves until you read again — the walk is what plants the aloha-id attribute in the new document |
| the element is removed, or stops being visible | gone — the next walk does not emit it |
Two consequences worth internalising:
gotodoes not give you refs. Read after navigating. Agotofollowed straight by aclickanswersExecution error (Error): Element with aloha-id 2a441adc not found, even when the ref is correct for the page it just landed on — and drops a JavaScript stack trace under it.- A ref is only meaningful on the page it came from. Refs are unique within a walk, not across the web: the same link text in the same structural position on two unrelated pages hashes to the same string.
Full descriptions, defaults and constraints: docs/tools.md — generated
from Sources/BrowserTools/Tools/Schemas.swift, with a test
(ToolsDoc/checkedInDocMatchesTheSchemas()) that fails if the two drift. The arguments
below were read back off a live tools/list, so they are the schema, not a paraphrase.
| tool | arguments | required |
|---|---|---|
manage_tabs |
action (list/read/open/close/use/unuse), tab_id, url, use (default true), controlled_by (agent/user, default agent), include_screenshot |
action |
page_click |
aloha_id, click_type (single/double/triple/right, default single) |
aloha_id |
page_type |
aloha_id, text, fields (array of {aloha_id, text, replace}, 1–20), replace (default true), submit (default false) |
either aloha_id+text, or fields |
page_select |
aloha_id, text, index (integer ≥ 0) |
aloha_id, plus either text or index |
get_text |
aloha_id (one, or up to 20 comma-separated), max_chars (default 20000, minimum 1) |
aloha_id |
page_navigate |
action (goto/back), url |
action |
page_press_keys |
keys — e.g. Enter, Escape, Control+a |
keys |
page_wait_for |
selector, timeout_ms (default 10000, clamped to 30000) |
selector |
page_upload |
aloha_id, paths (array of absolute paths, 1+) |
aloha_id, paths |
page_type and get_text are the two batch tools, and the batching is the point: filling
a five-field form is one call, not five rounds. Both caps are 20. page_type takes an
array, so its cap is declared in the schema as maxItems and a validating provider sees
it; get_text takes a comma-separated string, where maxItems is not expressible, so its
cap lives in the description and is enforced when the tool runs — ids past the twentieth
are not read, and the result says how many were.
A get_text batch is an error only when every id failed, so one stale ref does not
cost you the rest of the read. A batch that resolved at least one id exits 0 with the
failures named in place, one line per id: alohajet text a,b || handle will not catch a
missing element, and a script that cares has to read the labelled lines.
Only http and https URLs are accepted, and the scheme is not optional:
$ alohajet --launch open example.com
URL not allowed: malformed or oversized URL.That message is wrong about what is wrong. Write https://example.com.
Every variable the sources actually read, checked with
grep -rhoE 'ALOHAJET_[A-Z_]+|ALOHA_[A-Z_]+' Sources/.
| variable | default | effect |
|---|---|---|
ALOHAJET_BROWSER |
a system Chrome | path to the Chromium executable the default and --launch lanes run. A path that is not an executable file is an error (exit 3), never a quiet fall-through to another browser. Unset and with no system Chrome, Chrome for Testing 153.0.8010.52 is downloaded on first use — a 191 MB zip on mac-arm64, 196 MB on linux64, unpacked into ~/Library/Application Support/AlohaJet/chrome-for-testing. There is no pre-warm command and nothing cleans it up; rm -rf that directory. |
ALOHAJET_NETWORK_LOG |
off | a directory (or 1 for a temp dir) to record each agent-opened tab's requests as JSONL, 0600 in a 0700 directory. Read the limitation below before trusting it. |
ALOHAJET_CREDENTIAL_GUARD |
off | 1 makes page_type refuse to type into a field it classifies as a credential field. Password-field masking on read is always on and is not controlled by this. |
ALOHAJET_MARKDOWN_URLS |
off | 1 includes each link's href: [Learn more](https://iana.org/domains/example) {aloha-id="719a97a0" a} |
ALOHAJET_HIGHLIGHTS |
on | 0 skips the in-page click flourish. Cosmetic; the synthetic input event is sent either way. |
ALOHAJET_MAX_OBS_TOKENS |
off | cap one observation's estimated tokens, so a single huge page cannot blow the context |
ALOHAJET_COMPACT_TOOLS |
off | tighter default caps, for a small model |
ALOHAJET_CHROME_USER_AGENT |
Chrome's own | override the user agent of a browser alohajet launches. See the HeadlessChrome limitation below. |
ALOHAJET_DEBUG |
off | protocol chatter to stderr |
ALOHA_CDP_PORT |
9222 |
where --browser aloha looks for the Aloha browser's CDP listener |
ALOHA_BROWSER_APP |
the .app this binary ships inside |
path to the Aloha .app that --browser aloha launches. Unset, the lane launches the bundle this executable sits in — never another registered copy, which would be a second instance on one profile. |
ALOHAJET_AGENT_TOKEN |
read from disk | the bearer token -p sends to --endpoint. Unset, it is read from ~/Library/Application Support/Aloha/automation-token — but that ambient file is sent to a loopback endpoint ONLY, so a remote https endpoint is reachable and gets a token only from this variable. Absent entirely, no Authorization header is sent and the endpoint answers 401 — never a silent unauthenticated retry. |
Not a disclaimer. These are the things that will cost you a round trip, and each one was reproduced on this machine before it was written down.
Linux is built and tested, but nothing on this page was measured there. Every number, every transcript and the ref-stability proof were produced on macOS. The Linux job builds and runs the suite; it does not re-run the proof.
close refuses tabs it did not open. A tab that was already there when alohajet
attached is the user's; close says so and exits non-zero. Tabs alohajet opened close
normally, across processes, on every lane.
No coordinate clicking, and that is a real gap. Everything is addressed by ref, so
anything the DOM walk does not emit is unreachable: a <canvas> game, a WebGL viewport, a
map widget, a PDF in the browser's own viewer.
chrome-devtools-mcp has click_at;
chrome-agent is built entirely on coordinates.
alohajet has nothing for it. This is the trade that buys stable refs — a trade, not a free
win.
No extraction verb. There is no web_extract, no readability pass, no site-JSON
extractor, no "give me the article". nativeAgentToolNames in
Sources/BrowserTools/Tools/Tools.swift is exactly the tools above and none of them is an
extractor. What you get is the rendered DOM serialized to markdown.
Whole capability areas are simply absent. No performance traces or Lighthouse audits.
No console messages. No network-request inspection a model can query. No heap snapshots. No
device emulation, throttling, or viewport resize. No extension or PWA tools. No
evaluate_script. No hover, drag, or dialog handling. No screencast.
chrome-devtools-mcp has all of those across ~57 tools; this has a deliberately small set.
ALOHAJET_NETWORK_LOG is a debugging aid, and a rough one. The file is named from an
internal id you cannot correlate to anything the CLI prints — a tab printed as
915E5DE332A24AF2218E6A9717ED027E logged to tab-914C8BAA-7A1.jsonl — and the main
document request is never recorded, only subresources, so a page whose only request is its
own HTML produces an empty file. Response bodies are kept, with credential-named fields
inside them masked against a fixed sixteen-name list (access_token, client_secret,
refresh_token, password, …); everything else in a body reaches disk verbatim, and
nothing deletes these files.
The launched Chromium advertises itself as HeadlessChrome. Its User-Agent is
...HeadlessChrome/152.0.0.0 Safari/537.36, so a site that gates on it will refuse the
default lane. ALOHAJET_CHROME_USER_AGENT is one workaround; --cdp against a normal
browser is the other.
Screenshots are MCP-only and opt-in. include_screenshot: true costs a capture
round-trip and image tokens on every read, so it is off by default. The CLI cannot show you
one at all.
Two sessions as the same user share one browser. The default lane records its browser
in $TMPDIR/alohajet-<uid>/browser.json, so a second alohajet drives the same tab from
another terminal. Give the second one its own TMPDIR, or its own --cdp.
Chrome/Chromium only. It speaks CDP. Firefox and Safari are out.
Rough edges you will meet. A JavaScript stack trace leaks into tool output when a ref
is not found. page_type's result nudges CLI users toward
fields=[{aloha_id, text}, ...], a shape the CLI has no flag for — that text is written
for the MCP surface and emitted on both.
Before pointing this at a browser you are logged into: URL validation refuses everything that is not http(s) — file:, data:,
javascript: — on both the destination and the tab a tool is standing on; password fields
are masked in the page before their values cross the wire; the network log is off by
default. There is no host allow-list, no sandbox, and no prompt-injection detection.
--cdp and --browser aloha against your everyday browser hand an agent your logged-in
sessions, by design. page_upload reads any path the caller names and base64s it into the
page: the filesystem is a trust domain this package does not fence.
Two things that paragraph is too short to say. A CDP port has no authentication —
Chrome's --remote-debugging-port is unauthenticated by design, loopback binding is
Chrome's default rather than a promise this package makes, and any process running as the
same user can connect and read cookies or evaluate JavaScript on any origin. And no
notion of which host is acceptable exists: http://169.254.169.254/latest/meta-data/,
http://192.168.1.1 and an internal hostname on your VPN are all valid http URLs and
are all accepted. What is defended and what is deliberately not is set out per lane in
docs/threat-model.md.
BrowserToolSession is the supported entry point: connect, run a tool by name, shut
down. Everything else the libraries expose is plumbing, not a promise — at 0.x there is no
API stability guarantee. The snippet below was compiled and run as written, and it leaves
no browser behind.
import BrowserTools
let session = try await BrowserToolSession.launch(headless: true)
let page = await session.run("manage_tabs", arguments: [
"action": "open", "url": "https://example.com"
])
print(page.output) // markdown with {aloha-id="..."} refs
let click = await session.run("page_click", arguments: ["aloha_id": "719a97a0"])
print(click.isError == true ? "failed: \(click.output)" : click.output)
await session.shutdown()shutdown() must be awaited on every path. defer { Task { await session.shutdown() } }
schedules work the process exits before running, which leaks a headless Chromium and its
temp profile on every run.
BrowserToolSession.attach(port:), .attach(host:port:) and .attach(webSocketURL:)
connect to a browser you did not launch. run never throws; failures come back as a
RawToolResult with isError == true, which is what the model has to read anyway. The
package also exports an AgentDriver product — the seam behind -p, whose HTTP contract is
docs/agent-endpoint.md.
Apache 2.0 — see LICENSE.
This package vendors no source tree, and the four library products link nothing. The
released alohajet binary links the
MCP Swift SDK and its transitive
dependencies statically — their notices are in
THIRD-PARTY-NOTICES.