Skip to content

Automate with browser control

Beta

octo drives a real Chrome tab over the DevTools Protocol (CDP) — no headless-browser framework dependency, no separate runtime.

Terminal window
octo browser setup

Three ways the browser tool gets a page, tried in this order:

  1. A known debug port (browser.connect_port in config.yml) — connects directly, no fallback if it fails. This is what octo browser setup wires up for you.
  2. Attach via a specific profile (browser.attach_running: true plus browser.user_data_dir) — reads that profile’s DevToolsActivePort file and connects to the Chrome that owns it.
  3. Attach to any Chrome already running with remote debugging enabled (browser.attach_running: true alone, and also the default when nothing is configured) — discovers a debugging-enabled Chrome and reuses your logins; never hijacks a browser that doesn’t already have debugging turned on.

There is no launch fallback: when no attachable Chrome is found, the tool returns instructions for enabling the remote-debugging toggle rather than silently starting a throwaway instance that would carry none of your logins.

Whichever path connects, octo always opens a brand-new tab rather than reusing one you have open (including its own Web UI tab) — driving an existing tab requires an explicit pages/ select_page action. The connection is reused across the whole session, so you approve any debugger-attach prompt once, not on every navigation.

Terminal window
octo browser setup

Walks you through enabling chrome://inspect/#remote-debugging (Chrome 136 stopped honouring --remote-debugging-port when it points at the default user-data directory, and Chrome 144 added this in-browser checkbox as the way to debug that profile — which is the one carrying your logins), then probes port 9222 in a loop — confirming not just that it connects, but that a real page-level CDP call succeeds (a browser-level connection can succeed while page control still fails on recent Chrome). On success it saves browser.connect_port to your config; on failure it prompts you to flip the toggle and retry, or quit and resume later.

There’s no CLI command for this — recording is driven by the browser tool’s record_start / record_stop actions, either called by the model or via the Web UI’s Record button.

The key thing to understand: it records what you do, not what the model does. After record_start, the agent hands the browser to you; you perform the steps yourself, then tell the agent you’re done and it calls record_stop <name>.

What gets captured, per step: the action (click / type / select / upload / navigate), a selector anchored at the nearest element with a stable id (not a fragile position-based chain), the element’s visible text, and the URL — plus a redundant fingerprint of the target (alternate selectors built with different strategies, its role attribute, and the nearest label-like neighbor text), which is what lets replay survive wholesale class churn (see Replay below).

The recorder also captures what happens between your actions:

  • A click that triggers fetch/XHR activity gets an automatic network wait step; one that opens a modal / date picker / overlay gets an element wait — so replay never races ahead of a page that hasn’t settled.
  • A click that starts a browser download is upgraded to a download step bound to a file[] output, so replay captures the file.
  • Provably-redundant fumbles are compressed away deterministically before any model sees them: retyping the same field keeps only the final value, and a click-away-click-back detour collapses to the single final click.

A model pass then distills the raw capture — dropping dead-end detours, turning your specific values into {{param}} placeholders, and writing a description — but it can only reorder or rename real captured steps, never invent a new target: any refined step whose selector isn’t in the original capture is rejected in favor of the raw version (fingerprints are re-attached deterministically after the pass).

record_stop replies with a numbered run-plan — each step with what it does and what its check is — for you to confirm or correct before the recording is used.

Recordings are saved as plain YAML at ~/.octo/browser-recordings/<name>.yaml — readable, editable by hand, and diffable in git. (Before the recording rename this was ~/.octo/browser-skills/; an existing directory is migrated automatically.)

browser(action: "replay", name: "<recording>", params: { ... })

(run_skill remains accepted as a deprecated alias.)

Replay is deterministic — no model call in the common case. Each step waits for its target, executes, and (if the recording declares one) checks a verify. A few robustness details worth knowing:

  • A step recorded with a fingerprint re-identifies its target by scoring candidates (from the original selector, the alternates, and text/role scans) against the recorded text, role, tag and neighbor text. A page whose CSS classes all changed still resolves; a positional selector that now matches the wrong element is refused outright — an explicit failure into self-heal, never a silent wrong click. Older recordings without fingerprints keep the previous behavior: exact selector first, then an element carrying the same recorded label text.
  • Auto-inserted wait steps settle the page (network idle, or a specific element appearing) before the next action fires.
  • A download step clicks its trigger, waits for the browser download to complete, and binds the saved file’s path to its declared output.
  • If a step opens a new tab, later steps automatically follow it.
  • After typing, an unexpectedly empty field gets one clear-and-retype before the step is marked failed.

The result is a structured object — {recording, steps, outputs, self_healed?} — so a recording’s declared outputs (an extracted value, a downloaded file path) can feed directly into a workflow via recording("<name>", params).

A param declared with secret: true is a password-class value that never enters the conversation — not the transcript, not memory, not the provider’s context. Recording already guarantees the value never lands in the YAML (password fields are captured empty and declared without a default); replay completes the guarantee by collecting the value at runtime, out-of-band:

params:
- name: password
description: secret value (not stored; provide at replay)
secret: true

When a secret param has no supplied value, replay resolves it in this order:

  1. An explicit params entry from the caller always wins.
  2. The session cache — a value already provided this session is reused silently (in-memory only, evaporates when the session is deleted; never written to disk).
  3. EnvironmentOCTO_BROWSER_SECRET_<NAME> (param name uppercased, non-alphanumerics as _: passwordOCTO_BROWSER_SECRET_PASSWORD, api_tokenOCTO_BROWSER_SECRET_API_TOKEN). Set it in the process environment or in ~/.octo/serve.env (mode 0600, loaded at startup). Same-named params across recordings share one env value. This is the path for cron and other unattended replays.
  4. A masked prompt — the TUI reads with echo off; the Web UI shows a password field.

IM chats (WeChat, Telegram, …) deliberately can’t collect secrets: a value typed there persists in platform history, which no client-side care can undo. A replay that hits a missing secret on IM fails with an error pointing at serve.env or the Web UI / TUI instead. Error messages name the recording and the param — never the value.

If a step’s selector no longer matches anything — and only if a model is configured for this purpose — octo takes a text digest of the page’s currently interactable elements (selector + visible text, no screenshot, model-agnostic), and asks the model for a single corrected CSS selector given the intended action, the dead selector, and (when the step carries one) the recorded fingerprint — role, tag and neighbor text — turning the request from an open guess into constrained matching. The fix is retried once and, if it works, written back into the recording’s YAML — so a heal is durable, not a one-off patch you’d have to redo on every future replay. (The write-back re-marshals the YAML, so any hand-written comments in the file are dropped on the next healed replay; field values are kept. Version your recordings in git if you annotate them.)

Browser actions go through the same permission engine as any other tool — a click or a form submission on a logged-in account is treated as a real, high-impact action, not a read-only step. Screenshots only come back as an image block if the active model is vision-capable; a text-only model gets a text note instead of a rejected image.

Next: recordings compose directly into workflow scripts via recording("<name>").