co-browser · git:20260927.19ada76 · 2026-09-27 · sha256 5794096eebda2297
co-browser git:20260927.19ada76A
Immutable. This exact content never changes and is served at /api/v1/blob/5794096eebda2297.
---
name: co-browser
description: Drive one persistent, logged-in browser from the shell with `co browser`. Use when the user asks to open a page, log into a site, scrape/extract page content, fill forms, upload files, take screenshots, or automate any browser task — solo or with multiple agents sharing the browser.
---
# co browser Skill
Drive **one real browser** from the shell. The browser stays open between commands —
cookies and logins persist until `co browser close`. Every terminal on the machine
talks to the same daemon: one browser, one board, no matter where you type.
**Read the output as well as the exit code.** A selector that matched nothing
exits `1` and no browser open exits `3`, so `co browser ... && next_step` stops
where it should — but a click that landed on the wrong element still exits `0`,
and only the page (a screenshot, `get_text`) can tell you that.
## Step 1: Identity — who are you?
The daemon attributes every tab to a caller and uses that identity to stop two
agents from silently driving the same page. **Set `CO_WHO` explicitly** — shell
state does not survive between your tool calls, so a lone `export` is lost.
Either prefix every command:
```bash
CO_WHO=alice co browser go_to example.com
```
or keep the export and the commands in the SAME shell invocation:
```bash
export CO_WHO=alice && co browser go_to example.com && co browser get_text
```
(Some environments — e.g. Claude Code background jobs — are auto-identified,
but setting `CO_WHO` is always safe and never worse.) An anonymous caller still
works but gets **no contention protection**.
## Step 2: Check the board, then claim a tab
**One task = one tab.** Before touching the browser, see what's already happening:
```bash
co browser status # browser state, headless flag, last command, the board
co browser tab ls # every tab: who owns it, purpose, last command (--json for scripting)
```
**Solo (nobody else on the board)** — bare commands run on the shared `main` tab, no ceremony:
```bash
co browser go_to example.com # runs on 'main'
co browser get_text # still 'main'
```
**Concurrent (another agent or a second parallel task exists)** — open your own tab
and add `-t <name>` to EVERY command, including a quoted task. Pick an explicit literal tab
name — a `NAME=$(...)` capture is lost between tool calls:
```bash
CO_WHO=alice co browser tab open scrape --for "scrape pricing" --needs 10m
CO_WHO=alice co browser -t scrape go_to example.com/pricing
CO_WHO=alice co browser -t scrape "extract every plan and its monthly price"
CO_WHO=alice co browser tab close scrape # release when done
```
`--needs` is your estimate of how long you will hold the tab (`30s`, `10m`, `2h`).
Say it: other agents leave your tab alone until then, and `co browser tab ls`
shows them when you expect to finish. Without it, two minutes of silence is
enough for another agent to take your tab — which is wrong when you are waiting
on a slow page or a human.
When delegating browser work to subagents, write each subagent's tab name into its
prompt — parallel agents share the one logged-in browser through separate tabs.
**When the site opens its own tab** — a `target="_blank"` link, a "view invoice"
button, a payment popup — that page belongs to no session, so `tab ls` cannot
show it as yours and every `-t` command keeps running in the page you were on.
`tab ls` tells you one exists; these two reach it:
```bash
co browser list_pages # every real page, with who is driving it
co browser -t mytask switch_page 1 # your session now drives page 1
co browser -t mytask get_text # ...and every command after it
co browser -t mytask switch_page 0 # back to where you were
```
Indexes come from `list_pages` and shift when pages open or close, so read the
list again rather than reusing a number from earlier in the task. A page another
session is driving is refused by name — two agents cannot share one page, the
same rule as tabs.
## Step 3: Pick the right verb
Two ways to drive the browser — pick per command, mix freely:
**Direct functions** (deterministic, instant, free — no LLM). Use for every step you can spell out:
```bash
co browser go_to https://example.com/login # https:// assumed if omitted
co browser get_text # visible page text
co browser get_links_from_page # every link, one per line
co browser take_screenshot /tmp/page.png # saves a PNG, prints its path
co browser click_element_by_selector "button" --index=0 --text="Send message"
co browser type_text_by_selector "#email" "user@example.com"
co browser fill_text_by_selector "#invite" --stdin < invite.txt
co browser scroll # scroll the page (scroll 3 = fewer steps)
co browser get_current_url
```
`co browser help` prints the live list of every function with its arguments —
trust it over any example here. Calling a function with wrong arguments returns
its usage line: self-correct from that.
**Typing into whatever already has focus** — after a `mouse_click` into a field
that no selector reaches cleanly — is `keyboard_type`, and pressing keys is
`keyboard_press`. There is **no `type_text`**; that name returns
"unknown command" and costs a round trip. Use `fill_text_by_selector` to replace
a controlled input atomically, `type_text_by_selector` for humanized appending
when a selector works, and `keyboard_type` when only focus is available.
`keyboard_type` **appends** — it does not replace. To overwrite a field: click
into it, then `keyboard_press "Meta+a"` (macOS) and `keyboard_press "Backspace"`
before typing, or you get `1:15 AM09:00 AM`. And confirm the click actually landed
inside the input: if it missed, `Meta+a` selects the **entire page** instead and
your text goes nowhere while the screen turns blue. The screenshot after typing is
the only way to tell those two outcomes apart. (The verb is `scroll`, not `scroll_down`;
guessing a plausible name costs a round trip.)
**Never hunt for an item by scrolling a feed.** Infinite lists re-render as they
lazy-load, so scroll position is not reproducible: the same `scroll 6` lands
somewhere different each time, `Home`/`Meta+Home` do not return you to the top,
and the card you were aiming at moves between screenshots. Worse, clicking a
per-item menu you located by eye can hit a *neighbouring* item — on a destructive
action that is the wrong row deleted.
Extract the item's id once, then address it directly:
```bash
# 1. one script over the whole list, returning ids -- not screenshots
co browser -t mytab run_page_script /abs/find-id.js # match on text, return data-urn/data-id
# 2. open that item alone, where its controls cannot be confused with anything else
co browser -t mytab go_to "https://example.com/item/<id>/"
```
Six scroll-and-screenshot round trips became one script plus one `go_to` this way,
and the per-item menu became unambiguous because the page held exactly one item.
**What the page sent, and its cookies.** When the DOM does not explain what a
page did — which API it called, what came back, why a login did not stick — read
the network layer instead of guessing from screenshots. Your `-t` tab name says
whose traffic and which site's cookies you mean:
```bash
co browser -t mytab network clear # then do ONE action
co browser -t mytab click_element_by_selector "#save"
co browser -t mytab network requests --type xhr,fetch --status 4xx
co browser -t mytab network request 7 # headers + bodies of that call
co browser -t mytab network har start # record a whole task as a HAR file
co browser -t mytab network har stop # prints ~/.co/browser/har/mytab-<time>.har
co browser -t mytab cookies # this site's cookies
```
**Signed in in Chrome, signed out here?** `co browser import --profile "<name>" --domain <site> --dry-run`
lists what would come across; without `--dry-run` it asks the user once (the Keychain may
show a dialog) and writes the cookies through the browser's own API. It never overwrites a
site the target is already signed in to unless `--replace`. Cookies only, macOS Chrome only.
Header and cookie **values are shaped** (`x-sign: <32 hex>`, `<22 chars>`), never
printed, unless you pass `--raw`. Keep it that way: the shape is what you need to
understand an endpoint, and the value is someone's login — it must not go into a
prompt, a log or a message. Only use `--raw` when the user asked for the values.
**`"<instruction>"`** (natural language — an AI agent sees the page and works out
the steps). Use for judgment, not for steps you already know:
```bash
co browser "log in with the saved credentials and open my notifications"
```
Describe the **end state**, not the steps. A quoted task is a full agent run — many LLM
calls, possibly minutes — and model waits stay in the CLI process, so other tabs can continue. **Never wrap a quoted task in `timeout`**: killing the client
does not stop the run, it only orphans it (you pay for an answer nobody reads).
For state checks, prefer the free text probes first (exit 0 = matched):
```bash
co browser get_current_url | grep "/feed"
```
Only when the state is visually ambiguous, fall back to a short quoted task:
```bash
co browser "Look at the current page. Reply EXACTLY one of: STATE: feed | STATE: login | STATE: error"
```
## Step 4: Verify with evidence
- **Routine checks are text checks**: `get_current_url` / `get_text` after
navigation and clicks. Reserve screenshots for evidence gates.
- **Irreversible actions (submit, post, publish, pay) get the full gate**:
screenshot before, act **exactly once**, screenshot after. Never click the same
submit button twice — if the result is ambiguous, report uncertain state
instead of retrying.
- Long pages: dump once and read locally instead of re-extracting per chunk:
```bash
co browser get_text > /tmp/page.txt # one daemon round-trip
sed -n '1,200p' /tmp/page.txt
```
- To find stable selectors, save the DOM and grep it:
```bash
co browser save_page_context li_composer
grep -o 'aria-label="[^"]*"' ~/.co/browser_context/*li_composer*/page.html | sort -u
```
- Site-specific DOM logic belongs in skill-local JS run with an **absolute path**
(the daemon resolves relative paths against its own cwd, not yours):
```bash
co browser run_page_script /path/to/project/.co/skills/<skill>/scripts/extract-items.js
```
## Step 5: React to failures
Exit codes cover the daemon-level contract; the output text covers the action itself:
| Signal | Meaning | What to do |
|--------|---------|------------|
| exit `0` | success | continue (verify the page when it matters) |
| exit `1` | the action failed (e.g. "No element found for selector", bad arguments print the usage line), or the daemon is busy at connection capacity | adjust the selector or approach; on "busy", retry shortly |
| exit `2` | usage error (bad flags, empty `-t`, `tab` misuse, a `go_to` address that is not a web URL) | fix the command syntax |
| exit `3` | nothing to act on: unknown tab, or no browser open yet | `tab open` the name first / run the `go_to` the message names |
| exit `4` | tab busy — another agent is mid-task there | do NOT retry the same command — the error prints the exact commands to run instead; follow them |
The exit-3/4 error messages ARE the documentation — they always carry the current
recovery steps, so trust them over this table.
When a tab blocks you, read `co browser tab ls` before deciding. Each tab shows
when its owner expects to finish:
```
[scrape] https://... who=alice purpose='scrape pricing' open 3m
owner expects to finish by 14:20 (7m left) — leave this tab alone until then; the browser is free for your own: co browser tab open <name> --who <you> --for "<task>"
```
Inside that window, 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. Every agent here is
cooperative; tidying up after a dead peer is expected, taking a live agent's page
is not.
## Logging in
The default is a **visible** window — exactly what you want for a first login.
Never automate credentials: open the login page, tell the user, then check with
short waits — one short check per tool call, not one long blocking loop (a long
loop hits the tool timeout, and every poll refreshes your claim on the tab,
locking other agents out for its whole duration):
```bash
co browser go_to https://site.com/login
# tell the user to log in in the visible window, then per check-in:
sleep 15 && co browser get_current_url | tail -1
```
Repeat the check a handful of times; if the URL is still the login page after
that, stop and ask the user instead of looping forever. Logins live in the profile (`~/.co/browser_profile/`),
not the daemon — they survive `close` and restarts. Log in once, stay logged in.
Headless: `co browser --headless go_to ...` — the mode is fixed by whichever
command **starts** the daemon (`status` shows `headless=true/false`). To switch:
`co browser close`, then relaunch.
## Scripting hygiene
- Wrap **direct functions** that might block in `timeout 60 ...`; never `timeout` a quoted task.
- Take the data line with `tail -1` when output includes env banners.
- Batch related commands in one tool call; never spend a whole call on a bare wait.
- **`wait` is in seconds** — every other settle knob here is milliseconds
(`--wait_ms=2500`), so `wait 2500` looks right and means 41 minutes. It is
capped at 60s and refuses anything longer. For a settle longer than a second or
two you want a condition, not a sleep: `wait_for_element` / `wait_for_text`.
- Uploads: try `upload_file_by_selector 'input[type="file"]' <path>` first; if the
input is hidden behind a button, `upload_file_after_click_by_selector '<button selector>' <path>`.
## Troubleshooting
- **"Where is my window?"** — `co browser status` says `headless=true`? An earlier
command started the daemon headless. `co browser close`, rerun without the flag.
- **"tab busy"** — another task holds this tab. Run `co browser tab ls`, then use your own tab.
- **"Opening in existing browser session" / profile in use (shows a PID)** — a
manually opened Chrome or stale daemon holds the profile; kill that PID, retry.
- **`TargetClosedError` after a crash** — the page died under the daemon; run
`co browser open_browser` again before retrying the command.
- **"Chrome failed to start"** — usually ssh/cron without a desktop session (run
from a logged-in Terminal, or use `--headless`). Full launch log: `~/.co/browser.log`.
- **"did not finish within 120s — it was waiting on Chrome's cookie store"** — a
macOS Keychain prompt is waiting behind another window. The command was
cancelled and the tab is free; ask the user to answer the prompt, then retry.
- **"the browser daemon … is running but did not answer"** — the daemon is stuck.
`co browser close` finishes it (it stops a daemon that does not answer by force,
exits 1 to say so); logins survive in the profile.
- **Nuclear option** — only if `close` could not finish it: stop this one daemon
by the pid beside its socket. Never `pkill -f 'connectonion.cli.browser_agent[.]daemon'`:
it stops every daemon this user has, including other agents' isolated ones.
```bash
kill -9 "$(cat "${CO_BROWSER_SOCK:-/tmp/co-$USER/browser.sock}.pid")" # Linux
kill -9 "$(cat "${CO_BROWSER_SOCK:-$(getconf DARWIN_USER_TEMP_DIR)co-$USER/browser.sock}.pid")" # macOS
```
## Done checklist
- [ ] Identity attached to every command (`CO_WHO=... co browser ...`)
- [ ] Board checked (`status` / `tab ls`) before claiming a tab
- [ ] Own tab used if any other agent/task shares the browser (`-t` on every command)
- [ ] Output text read on every command, not just exit codes
- [ ] Irreversible actions performed exactly once, with before/after screenshots
- [ ] Tab closed (`co browser tab close <name>`) when the task is done
- [ ] Browser left open for the next task unless the user asked to `close`