Skip to content

Repository files navigation

alohajet

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.


Requirements

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.

Install

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-cli

The 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")

A real session

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.

The two surfaces

CLI

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 unreachable

One 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 --headless

MCP

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

mcp --endpoint <url> — the other product behind the same verb

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.

The two browser lanes

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

Stable element refs

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}

How a ref is derived

There is exactly one place a ref comes from — alohaIdFor in Sources/BrowserTools/Runtime/DomTreeScript.swift. It hashes a string built from two parts:

  1. 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.
  2. 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 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"]').

The proof

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

rerender.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}

What invalidates a ref

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:

  • goto does not give you refs. Read after navigating. A goto followed straight by a click answers Execution 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.

The tools

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.

Configuration

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.

Limitations

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.

Security

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.

Using it as a library

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.

License

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.

About

Drive a real browser from the command line and from MCP. Stable element refs, zero dependencies.

Topics

Resources

Contributing

Stars

19 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages