CLI: Browser & Filesco browser

CLI Browser

Drive one real browser from the shell — call browser functions directly, or hand a task to the AI agent.

Quick Start (60 seconds)

co browser go_to news.ycombinator.com    # opens a browser, navigates
co browser get_current_url               # → https://news.ycombinator.com/
co browser take_screenshot /tmp/shot.png # saves a PNG, prints the path
co browser close                         # done

The browser stays open between commands. Each co browser ... call drives the same window — your navigation, cookies, and logged-in session persist until you close.

Choosing the browser engine

ConnectOnion 1.8 resolves the engine once when the browser daemon starts:

co browser go_to example.com                  # default: Patchright + system Chrome, $0
co browser --engine system go_to example.com  # the same thing, said explicitly
co browser --engine wtf go_to example.com     # paid WTF Browser, and only when asked

Paying is opt-in. An ordinary command never starts a billable session, even on a machine where Onionwright is installed and in credit.

The paid path requires Onionwright 0.0.14 or newer. Install or upgrade the real private wheel explicitly:

co browser install-onion

This command uses the current ConnectOnion login, verifies the release feed with ConnectOnion's pinned Ed25519 public key, verifies the wheel checksum from that signed manifest, and only then invokes this Python interpreter's pip. It does not start the browser daemon or a paid session, and downloading/installing the client costs $0. The real wheel comes from OpenOnion's authenticated artifact endpoint, not the public PyPI placeholder.

mode behavior
auto The default, and what every command that names no engine sends. Resolves to system Chrome without importing Onionwright, reading paid credentials, calling oo-api, or downloading an artifact. Cost: $0.
system The same resolution, requested explicitly.
wtf Require the compatible Onion artifact and enough balance. Any preflight failure is returned as a typed error; there is no silent system fallback. onion, the older spelling, is still accepted and means the same.

A daemon is pinned to the engine it started with. When you select an engine explicitly, keep that flag on subsequent commands, including close. Starting fresh with no engine gives you system Chrome again. This is session selection, not automatic read/write switching; there is no separate paid UI panel.

Artifact checking and download do not charge. A paid session starts only after the complete artifact is locally ready, then prepays $0.025 for one 15-minute interval. Renewal occurs before the signed deadline. If renewal fails, that exact Onion process stops at paid_until; it is never changed into system Chrome mid-session.

The daemon is pinned to its chosen engine. Close it before changing modes:

co browser close
co browser --engine wtf go_to example.com
co browser --engine wtf get_text
co browser --engine wtf close
co browser go_to example.com  # a fresh default session is free again

System Chrome and Onion Browser use separate persistent profiles. Cookies and fingerprint state are not silently copied between them. co browser status shows requested/resolved engine, typed reason, and exact artifact when paid; it never prints tokens, licence bytes, or paid-cache paths.

Why Use This

Two ways to use a browser from the CLI, and you pick per command:

  • Direct function call — co browser go_to x.com. Deterministic and instant, with no LLM charge; browser runtime cost follows the selected engine. Great for scripting and exact steps you already know.
  • Natural language — co browser "find the cheapest flight". The AI agent figures out the steps. Great when you don't want to spell them out.

Both drive the same live browser, so you can mix them: script the boring parts, let the agent handle the hard part.

co browser go_to myapp.com/login
co browser "log me in and open the billing page"   # agent takes over the same window
co browser take_screenshot /tmp/billing.png           # back to a direct call

How It Works

The first co browser command starts a small background daemon that owns one browser. Every later command connects to it over a local socket and drives that same browser. The daemon lives exactly as long as the browser:

co browser go_to x.com   ──► starts daemon ──► opens browser ─┐
co browser click "Login" ──────────────────► same browser    │  state persists
co browser screenshot    ──────────────────► same browser    │
co browser close         ──► browser closes ──► daemon exits ─┘

You never manage the daemon directly — the first command starts it, and close (or closing the window) stops it. There is no separate "start" step.

How a command is dispatched

The first word is compared against the browser's function names:

You type What happens
co browser go_to x.com go_to is a function → runs it directly
co browser "..." Hands one quoted task to the AI agent
co browser frobnicate matches nothing → unknown command: frobnicate (exit 1)

Quote natural-language instructions: co browser "click the blue button". A bare word that happens to be a function name (like click) is treated as a direct call, not language.

Discovering Functions

The CLI describes itself — run help to list every callable function with its arguments and a one-line summary (no browser is launched):

co browser help
Functions:
  go_to(url) — Navigate to a URL.
  take_screenshot(path=None, full_page=False) — Take a screenshot of the current page...
  click(description) — Click on an element using natural language description.
  get_links_from_page(domain_filter='') — Extract all unique links from the current page...
  ...

This is the fastest way — for a person or an AI agent — to find the exact function name and arguments before calling it.

Common Functions

Any function listed by co browser help is callable. The ones you'll reach for most:

co browser go_to <url>                     # navigate
co browser get_current_url                 # print the current URL
co browser get_text                        # print visible page text
co browser take_screenshot /tmp/shot.png [--full-page]
co browser click "<description or selector>"
co browser type_text_by_selector <css> "<text>"
co browser fill_text_by_selector <css> --stdin < secret.txt  # replace controlled input; secret stays out of argv
co browser get_focused_element             # bounded JSON; password values are redacted
co browser keyboard_press Meta+a           # refused unless focus is editable
co browser get_links_from_page             # one link per line
co browser scroll                          # scroll the main content
co browser close                           # close browser, stop daemon

Arguments are plain strings; flags like --full-page and --index=2 map to the function's parameters, and --index 2 or --full-page true mean the same as the = forms. For fill_text_by_selector, type_text_by_selector, and keyboard_type, a final --stdin reads the text from redirected standard input so passwords and one-run codes do not appear in process arguments. Prefer fill_text_by_selector when replacing a controlled framework input; use type_text_by_selector when appending human-shaped keystrokes is required.

keyboard_type pastes Chinese, Japanese, and Korean runs and falls back to IME composition only when the field's text did not change at all. An empty rich-text editor (Feishu, Lark, Slate) replaces a zero-width placeholder on the first paste, so "grew by the full length" is the wrong test; since 1.8.9b19 the text is typed once, not twice (#1877). A paste that lands partly is not retyped, so still screenshot before pressing Enter.

Before replacing focused text with a keyboard shortcut, inspect the target:

co browser get_focused_element
co browser keyboard_press Meta+a       # macOS
co browser keyboard_press Control+a    # Windows/Linux
co browser keyboard_press Backspace

get_focused_element follows focus into open shadow roots and reports whether the target is editable. Its value preview is bounded, and password values are always redacted. keyboard_press refuses select-all, Backspace, and Delete when focus is outside an editable input, textarea, or contenteditable element. For a deliberate page-level shortcut, acknowledge the risk explicitly with --allow-non-editable.

Focus inspection is scoped to the top-level document and open shadow roots. If focus is inside an iframe, the result describes the iframe itself and treats it as non-editable; closed shadow roots cannot be inspected. These cases fail safe: target the field by selector, or use the explicit override only after verifying the frame and intended page-level action.

Use absolute paths for files the browser reads. upload_file_by_selector and run_page_script resolve a relative path against the daemon's working directory (where it was first started), not the directory you run each command from. Screenshots are the exception: the image is streamed back and saved by your own command, so its path is relative to where you ran it.

Screenshots

take_screenshot writes a PNG and prints where it saved — not the image data:

$ co browser take_screenshot /tmp/shot.png
Screenshot saved to: /tmp/shot.png

A relative path is resolved against the directory you ran the command in. Omit the path and it is named for you under .tmp/screenshots/ in that directory:

$ co browser take_screenshot
Screenshot saved to: /Users/you/project/.tmp/screenshots/screenshot-3f9a1c2b7d4e.png

Add --full-page to capture the entire scrollable height instead of just the viewport.

Why a path, not the image? The underlying take_screenshot() function returns a base64 data URL — that's what the AI agent "sees" during a quoted task. A direct CLI call deliberately prints the file path instead, so co browser take_screenshot never floods your terminal with a screenful of base64. Open or pipe the saved file when you want the actual image.

Scripting

Output is clean stdout, errors go to stderr, and the exit code is 0 on success / 1 when the action failed (a selector that matched nothing, a missing script, a page index that does not exist, or a command that ran out of its 120-second deadline) / 2 for a usage error (wrong arguments for a function, or a go_to address that is not a web URL) / 3 when there is nothing to act on (no browser open yet, or an unknown -t tab) / 4 when another agent holds the tab / 5 when a quoted task has no account or credentials to run its model (co auth) / 6 when the running daemon is pinned to a different engine — so commands compose like any Unix tool:

# Capture a value
url=$(co browser get_current_url)

# Pipe list output (one item per line)
co browser get_links_from_page | grep github | wc -l

# Fail-fast in a script
co browser go_to "$DEPLOY_URL" && co browser take_screenshot /tmp/deployed.png

Sharing the Browser With Other Agents

One machine, one browser, often several agents. They stay out of each other's way through named tabs — and by saying how long they expect to need one.

co browser tab open scrape --who alice --for "scrape pricing" --needs 10m
co browser -t scrape go_to example.com/pricing    # -t on EVERY command
co browser -t scrape get_text
co browser tab close scrape                        # release when done

--needs takes 30s, 10m, or 2h. It is not a lock — it is the estimate other agents read before touching your tab:

co browser tab ls
Tabs (2):
   [scrape] https://example.com/pricing  who=alice  purpose='scrape pricing'  open 3m
      last: get_text · 12s ago
      owner expects to finish by 14:20 (7m left) — leave it alone until then
   [stale] https://...  who=bob  purpose='check stock'  open 2h
      owner expected to finish by 12:30 (1h ago) — free for another agent to close

Inside the window, leave it alone — open your own tab instead. Once it has passed, the tab is free, and closing it is a courtesy: an estimate that ran out with the tab still open means that agent crashed, not that it is still working.

A tab opened without --needs frees up after ~2 minutes of silence, which is wrong whenever you are waiting on a slow page or a human. Say the number.

Named tabs are also the concurrency boundary. The daemon owns one asyncio browser runtime: two operations aimed at scrape queue behind each other, while scrape and inbox may run at the same time. A registry lock decides claim races before either operation touches a page, so concurrency never means two agents silently sharing one tab.

Set CO_WHO so your commands carry your identity:

export CO_WHO=alice

Headless vs GUI

By default the browser is visible (a real Chrome window you can watch). Add --headless for scripts/CI:

co browser --headless go_to example.com    # no window
co browser go_to example.com               # visible window (default)

The mode is fixed when the daemon starts (the first command that needs one — a read such as get_current_url with nothing running answers exit 3 without starting it). A --headless that reaches a daemon already showing a window prints a note on stderr instead of being ignored. To switch modes, co browser close first, then start again with the mode you want.

Natural Language Agent

A quoted task runs the full AI browser agent on the live browser and prints its final answer:

co browser "search for wireless headphones and list the top 3 prices"

This path uses managed keys — run co auth once if you see an authentication message.

Installation

None needed (1.2.1+). The Patchright library ships with connectonion, and the first page-driving command auto-installs a browser when none exists — a one-time download, announced in your terminal, into your per-user directory (no admin rights). If a desktop Google Chrome is installed at the standard location, it is detected and used instead, with zero downloads.

Manual fallback (older versions, airgapped machines, or a failed auto-install):

python -m patchright install chromium   # per-user, never needs admin
python -m patchright install chrome     # branded Chrome: best stealth, system installer

Sessions & Profile

  • One async browser runtime per machine, backed by a persistent profile at ~/.co/browser_profile/ — so logins survive restarts.
  • The daemon endpoint: a Unix socket at /tmp/co-<user>/browser.sock on Linux and <per-user temp dir>/co-<user>/browser.sock on macOS — the same however you logged in, since no session variable moves it — and a per-user named pipe on Windows (native, 1.2.1+ — no WSL). Override with $CO_BROWSER_SOCK. A daemon an older version started at $XDG_RUNTIME_DIR/co/, /run/user/<uid>/co/ or $TMPDIR/co-<user>/ is still found until it is closed.
  • Client work is bounded: 1 MiB request cap, 120-second read/reply deadlines, a 120-second deadline on the command itself (cancelled past it, which frees its tab), 32 admitted connections (a command beyond them is told the daemon is busy at connection capacity — except status, close, tab ls and tab close, which have eight connections of their own), and eight blocking transport workers on Windows. A client that disconnects takes its command with it. The after-reply liveness check runs once the connection is closed and gives Chrome 3 seconds; no answer is never read as a dead browser.
  • The client waits 30 seconds for status/tab ls, and for other commands the daemon's own deadline — 120 seconds, or a longer --timeout plus 15 — and 10 seconds more for the answer to arrive. A read (get_current_url, list_pages, cookies, save_state, ...) asks status every 10 seconds while it waits, so against a frozen daemon it gives up as soon as status would, while one queued behind a slow command on a live daemon keeps waiting. A daemon that says nothing (stopped, wedged) is reported as not answering, and co browser close stops it by force and removes the socket, .pid and .lock it left.
  • With no daemon running, reads (get_current_url, list_pages, cookies, save_state, tab ls, tab close, status) answer that no browser is open and start nothing, so a later --headless command still decides the window mode.
  • On Windows, co browser close returns only after the serving daemon exits, so an immediate next command can safely start a fresh daemon.
  • For an isolated automation run, set $CO_BROWSER_PROFILE_DIR to a dedicated absolute directory and $CO_BROWSER_SOCK to a dedicated socket. Keep the real $HOME; replacing it can break OS-backed browser behavior and credentials. The paid Onion engine honours it too: its profile goes in $CO_BROWSER_PROFILE_DIR/onion instead of ~/.onionwright/profiles/<address> (a separate folder because it is a different Chromium build from the system Chrome the free engine opens in the directory itself).
  • A supervisor can set $CO_BROWSER_DAEMON_PID to the positive PID of its already running local daemon. Commands then fail if that recorded owner is missing, replaced or unavailable; they never start a daemon or install a browser. Engine probes, reconnects and read keepalives retain the same pin. A close never follows a replacement owner for force cleanup. Keep this environment local to the supervised job and use a private endpoint/profile; the PID is a lifecycle constraint, not authentication or a paid-session ID. Explicit Host private targets ignore this local pin. Without it, ordinary first-command startup works as before. Pinned ordinary commands also skip automatic paid-client installation; an explicit install-onion command remains an installation request.

Importing logins from Chrome

A new browser profile starts signed out of everything, and signing in again from a new browser is exactly what some sites flag as unusual. co browser import carries the session you already have in Google Chrome into the co browser profile instead:

co browser import --profile "Profile 1" --domain linkedin.com --dry-run   # sites and counts, nothing written
co browser import --profile "Profile 1" --domain linkedin.com             # asks once, then imports
co browser --engine wtf go_to https://www.linkedin.com/feed/              # check it landed signed in
  • --profile takes Chrome's folder name (Default, Profile 1) or the name Chrome shows for the profile (openonion). Default: Default.
  • --domain keeps one site and its subdomains; repeat it for more. Without it every site in the profile is imported.
  • --engine picks the target. Without it the import goes to the engine co browser config names, else to the paid WTF Browser, since that is the profile that starts empty. A real import starts a browser session, and a WTF Browser session is billed.
  • --dry-run lists sites and cookie counts only: no Keychain, no browser.
  • --yes skips the one confirmation; without a terminal it is required.
  • A site the target browser is already signed in to is left alone and reported as "already signed in in the target", naming the login cookie it found, so an import never switches an account the target is using. Signed in means a login cookie, not any cookie: user_session on github.com, li_at on linkedin.com, SID/__Secure-1PSID on google.com, auth_token on x.com, c_user on facebook.com, _aat on airbnb.com; on other sites a Secure, HttpOnly cookie named like a session (session/auth/token/sid) that Chrome's profile also has. Anonymous cookies from an earlier visit do not count: the import goes ahead and reports how many it replaced. --replace imports over a real login; cookies with the same name, domain and path are overwritten and the others stay.

How it works: the command reads a private copy of Chrome's cookie database (Chrome locks the file while it runs, and the source profile is never modified), decrypts it with the "Chrome Safe Storage" key from the macOS Keychain the way Chrome does, and writes each site through the target browser's own cookie API (Playwright add_cookies) — so the target encrypts them however it stores cookies, including a mock keychain. Cookie values are never printed. The report names every cookie that was skipped and why: expired, partitioned (it belongs to one embedding site), undecryptable, or rejected by the target browser.

Limits of this first version:

  • macOS and Google Chrome only. Other browsers and platforms are not read yet.
  • Cookies only. Local storage, saved passwords, history and extensions are not imported; saved passwords are never read.
  • The Keychain dialog. macOS may ask whether security may read "Chrome Safe Storage". That is the key Chrome encrypts its cookies with; choose Allow. Deny, and nothing is imported.
  • Device-bound sessions. A site that ties its session to the device (Google, some banks) may still ask you to sign in. Verify with co browser --engine wtf go_to https://<site>.
  • Playwright cannot create host-only cookies, so a cookie Chrome held for one exact host is set as a domain cookie for that host; a __Host- cookie may be rejected for the same reason and is reported as such.

Error Messages

go_to returns exit code 1 with BrowserNavigationError when the proxy rejects authentication or Chromium returns a network error instead of the destination document. The code is NAVIGATION_PROXY_AUTH_FAILED for an observed 407 or explicit authentication error, and NAVIGATION_NETWORK_ERROR for other Chromium network failures (including rejected challenges surfaced that way by the driver). These messages omit URLs and driver logs that may hold credentials. An ordinary empty page or HTTP 404 document remains readable and is not treated as a transport failure.

An address that cannot load at all is refused before anything starts, with exit code 2: a scheme other than http, https, file, data, about or chrome, or text that is not a host name.

$ co browser go_to htp://example.com
go_to 'htp://example.com': htp: is not a web address this browser opens
Next: co browser go_to https://example.com   (http, https, file, data, about and chrome addresses)

Errors print to stderr and exit non-zero (1 for a failed action; see Scripting for the others). Each one tells you the next step — handy when an AI agent is driving the CLI and needs to self-correct.

Unknown function

$ co browser frobnicate
unknown command: frobnicate
Run 'co browser help' to list functions, or 'co browser "<instruction>"' for natural language.

The first word didn't match any browser function. List them with co browser help, or quote a complete task: co browser "<instruction>".

Wrong arguments

$ co browser go_to
go_to: missing a required argument: 'url'
usage: co browser go_to(url, purpose='', who='', hours=0.0)

The function exists but the arguments don't fit, so it exits 2 (usage) without running. The usage: line shows the exact signature — pass the missing argument: co browser go_to example.com. A value of the wrong type (wait abc) is the same kind of error.

Ran out of time

$ co browser cookies --all
cookies did not finish within 120s — it was waiting on Chrome's cookie store — on macOS it waits on a Keychain prompt until someone answers it (look behind other windows).
It was cancelled; tab 'main' is free again.
Next: retry once that answers, or start over (logins are kept): co browser close

Every command is answered within 120 seconds, or its own longer --timeout plus 15; the client gives up 10 seconds after that if a stopped daemon never answers. A driver timeout reads the same way — go_to timed out: Timeout 30000ms exceeded. — with a next step, not an exception name and a call log.

Authentication required (only for a quoted browser task)

$ co browser "find the price"
Browser agent requires authentication. Run: co auth

The natural-language agent uses managed keys. Run co auth once. Direct function calls don't need this.

Patchright not installed

Browser tools not installed. Run: pip install patchright && patchright install chrome

Troubleshooting

# Nothing happens / stuck browser → close and start fresh
co browser close

# See what the agent/daemon is doing
cat ~/.co/browser.log

# Authentication needed (only for a quoted browser task)
co auth

See Also