surf-cli

nicobailonMIT活跃维护

让 AI Agent 通过命令行直接控制 Chrome 的 CLI 工具,零配置、不绑定特定 Agent,开箱即用。

The CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.

v2.26.0发布于 2026-11-06 06:08 UTC687star +2 / 7 天62fork同步于 2026-11-10 11:56 UTC

怎么上手

由 README 提炼,执行前请确认
npm install -g surf-cli
surf install
硬件要求按 README 说明

README 未说明显存或 GPU 要求;需在 macOS、Linux 或 Windows 上安装 Chrome 等浏览器并加载扩展,支持 chrome、chromium、brave、edge、arc、helium。

下载:源码 zip·GitHub Releases(安装包)本站不镜像安装包

项目说明

由 README 自动翻译提炼

让 AI 代理通过纯 CLI 命令控制 Chrome 的零配置工具,不绑定任何模型

让 AI Agent 通过命令行直接控制 Chrome 的 CLI 工具,零配置、不绑定特定 Agent,开箱即用。

核心能力

  • 通过 Unix socket 执行纯 CLI 命令,任何能跑命令的代理都能用
  • 用无障碍树生成稳定元素引用 e1、e2,可直接点击和输入
  • 支持按 ARIA 角色、文本、表单标签做语义定位并直接执行动作
  • 自动记录网络请求,可过滤、搜索并重放 API 调用
  • 复用浏览器登录态查询 ChatGPT、Gemini 等,无需 API key

容易踩的坑

装完 CLI 还需在 chrome://extensions 开启开发者模式并手动加载 surf install 打印的扩展目录;需保持开发者模式开启,否则自动重载的未打包扩展会被 Chrome 禁用;通过 Nix、Homebrew 等包管理器安装时需先设置 SURF_NODE_PATH、SURF_HOST_PATH、SURF_EXTENSION_PATH 环境变量。

内容由模型根据 README 生成,可能不完整或不准确,以官方文档为准。看原文 README

最近发版

5 个版本v2.26.0
  1. v2.26.0最新正式版2026-11-06 · 2026-11-06 06:08 UTC

    Surf now tells you when something didn't work. Clicks, typing, dropdown choices and locate actions used to print OK even when nothing happened. Now they fail with the reason, for example a disabled button, a cookie banner in the way, or text longer than the field allows. Keeping Surf current takes one command, surf update, and setup no longer needs an extension ID copied from Chrome. surf read also shows more of real pages, including forms built from custom components, styled checkboxes, and what each field currently holds.

    Highlights

    • Clicks and typing fail honestly. Surf says why an action didn't work (e5 is disabled, e2 is covered by dialog "Cookie consent") instead of printing OK. Clicks wait for the element to be ready and use a real mouse click, and locate no longer clicks "Save draft" when you asked for "Save".
    • One command to update. surf update updates the CLI, the browser connection and the agent skill, then reloads the extension. When the parts get out of step, every command warns you.
    • read sees more of the page. It now lists form fields inside custom components (such as SmartRecruiters job forms) and styled checkboxes, along with what each text field holds. Passwords are never shown. It also counts the controls further down the page.
    • New commands. surf check and surf uncheck set a checkbox to the state you want. surf page.text --ref prints a field's whole value. surf type --escapes types real newlines and tabs.

    Need to know

    WhoWhat to do
    Existing Surf usersSurf's extension ID changes once. Run surf install, then reload Surf in chrome://extensions. Settings saved in the old copy, such as Debug Mode, don't carry over.
    Scripts that relied on OKActions that used to print OK without working now exit with an error. These include clicking a disabled or covered element, an ambiguous locate --action, text too long for its field, and a dropdown value with no matching option.

    Highlights

    • When a click, typed text, dropdown choice or locate action doesn't work, Surf now fails and says why (e5 is disabled, e2 is covered by dialog "Cookie consent") instead of printing OK. Clicks also wait for the element to be ready and use a real mouse click.
    • surf update updates the CLI, the browser connection and the agent skill, then reloads the extension, all in one command. surf install no longer asks for an extension ID.
    • surf read sees more of the page: form fields inside custom components, styled checkboxes, what each text field holds (never passwords), and a count of controls further down the page.
    • New surf check and surf uncheck set a checkbox to the state you want, and surf page.text --ref prints a field's whole value.
    • If you already use Surf, its extension ID changes once: rerun surf install, then reload Surf in chrome://extensions.

    Added

    • surf check and surf uncheck set a checkbox to the state you want - Clicking a checkbox toggles it, so an agent had to read its state first or risk unticking a box that was already ticked by default. surf check <ref> and surf uncheck <ref> leave a checkbox, switch or radio alone when it is already in that state (e6 was already checked; nothing clicked.), and otherwise click it like a real mouse would, so pages that keep their own state in click handlers (such as React apps) see the change. If the control still isn't in the requested state after the click, the command fails. uncheck on a radio button fails and says to pick another option in its group. Thanks to @morgan55555 for #436.
    • Surf can start your browser for you - Set "browser": { "autostart": true } in surf.json and a command run while the browser (or its Surf host) isn't running starts it, waits up to 30 seconds for Surf to connect, then runs. surf go <url> opens the browser on that page
  2. v2.25.02026-10-26 · 2026-10-26 04:17 UTC
    查看更新说明

    Surf 2.25.0 lets you send its AI page checks through your own Cloudflare AI Gateway, so you can see and control those calls from Cloudflare's dashboard. Set one environment variable and the semantic commands that run on Cloudflare's models go through your gateway, using the token you already have. This release also fixes output being cut off when you pipe Surf into another program, so scripts and agents reading Surf's output always get all of it.

    Highlights

    • Your own AI Gateway for Surf's Cloudflare calls: set CLOUDFLARE_GATEWAY_ID and the calls show up in your gateway's logs and analytics and follow its rate limits and caching. Nothing changes if you don't set it.
    • Piped output stays complete: surf page.read | grep price, or an agent reading surf --help-full, no longer stops partway through when the reading program is slower than Surf.

    Full changelog

    Highlights

    • If you run Surf's AI page checks on Cloudflare, you can now send those calls through your own Cloudflare AI Gateway by setting one environment variable. They then show up in the gateway's logs and analytics and follow its rate limits and caching.
    • Output piped from Surf into another program, such as grep or an agent reading surf --help-full, is no longer cut off partway through.

    Added

    • Cloudflare AI Gateway routing - Surf's semantic commands, which ask an AI model to judge or extract from a page, can run on Cloudflare's Clef and Clairic models. Set CLOUDFLARE_GATEWAY_ID to your gateway's id and those calls go through that Cloudflare AI Gateway, so they show up in its logs and analytics and follow its rate limits and caching. It uses the Cloudflare token you already set up; no second token is needed. The gateway logs what Surf sends, which includes page content. Without the variable, calls go straight to Workers AI as before.

    Fixed

    • Piped output is no longer cut off - When you piped Surf's output into another program, such as surf page.read | grep price or an agent reading surf --help-full, the output could stop partway through if that program read more slowly than Surf wrote. Surf now finishes writing all of its output before it exits.
  3. v2.24.12026-10-24 · 2026-10-24 22:18 UTC
    查看更新说明

    This is a security and fix release; update if you use surf gemini. Surf now checks that it is really talking to Google before sending your Google login cookies. Earlier versions skipped that check, so someone intercepting your network could have posed as Google and copied them. surf gemini also works again, after every request had been failing with Header overflow. Sessions opened in their own window now stay there.

    Highlights

    • Your Google cookies only go to Google. surf gemini rejects fake, expired or mismatched certificates, like any other HTTPS client.
    • surf gemini works again. Gemini's large responses no longer fail with Header overflow.
    • Sessions keep their own window. A session's tab no longer moves into the window you are using, so clicks, drags and scroll collection work on it.

    Need to know

    The risk needed someone actively intercepting your connection while surf gemini ran, for example on a hostile public Wi-Fi network. Ordinary networks were not affected. If you ran surf gemini on a network you don't trust, open your Google Account's security settings, go to Your devices, and sign out of sessions you don't need. That makes any copied cookie useless.


    Highlights

    • surf gemini now verifies it is really talking to Google before sending your Google login cookies. Earlier versions skipped that check, so someone intercepting your network could have posed as Google and copied them.
    • surf gemini works again; every request had been failing with Header overflow.
    • A Surf session opened in its own window now stays there instead of moving into the window you are using, so clicks, drags and scroll collection work on it.

    Fixed

    • Sessions stay in their own window - surf session.ensure <name> <url> opened a new window, but its tab was then moved into the window you were using, next to your tabs and behind the one you were looking at. Commands that need a visible tab, like drag, clicks by coordinates and collect, then did nothing. The tab now stays in the session's window. See #403.
    • surf gemini works again - Every surf gemini request failed with Header overflow, because gemini.google.com sends more response headers than Node accepts by default. The client now accepts them.

    Security

    • surf gemini checks Google's HTTPS certificate - The Gemini client turned off certificate checks on the requests that carry your Google session cookies, so anyone able to intercept your network traffic, such as on public Wi-Fi, could pose as Google and read those cookies. It now rejects a certificate that is self-signed, expired or issued for another host, like any other HTTPS request. If you ran surf gemini on a network you don't trust, sign out of other sessions in your Google Account's security settings to invalidate any copied cookies.
  4. v2.24.02026-10-24 · 2026-10-24 21:20 UTC
    查看更新说明

    Agents now spend far less of their context learning Surf. The skill they read before driving your browser is about 80% smaller, and they look up the rest only when a task needs it. Everything the skill and the help show now works: commands that failed and flags that did nothing are gone, every command answers --help, and a mistyped command points at the right one. A few commands that quietly did the wrong thing now do what they say, including drag, right-clicks and type --submit.

    Highlights

    • About 80% fewer tokens to learn Surf. The skill went from about 10,600 tokens to about 2,100. Agents get the rules and everyday commands up front, look up any other command with surf <command> --help (about 100 tokens) the first time they need it, and read the notes on remote browsers, ChatGPT consults or semantic models only when a task calls for them. On 15 typical browser tasks, agents picked a valid, correct command as reliably as with the old skill.
    • Help you can trust. surf <command> --help covers every command and lists only flags that do something, with examples for the tricky ones. surf --find thread finds surf use x thread, and surf bookmarks suggests bookmark.list instead of failing in the browser.
    • Commands that do what they say. surf drag --from x,y --to x,y drags, surf click e5 --button right right-clicks, surf type "…" --submit presses Enter, and the browser shows the extension's real version instead of 2.6.0.

    If you installed the skill by copying SKILL.md on its own, copy or symlink the whole skills/surf folder instead, so its references/ files come along.


    Highlights

    • Agents spend about 80% fewer tokens learning Surf. The skill they load before driving the browser went from about 10,600 tokens to about 2,100, so every task starts with more room for page content. Agents get the rules and everyday commands up front, look up any other command with surf <command> --help (about 100 tokens) the first time they need it, and open the notes on remote browsers, ChatGPT consults or semantic models only when a task calls for them. In a side-by-side test on 15 browser tasks, agents picked a valid, correct command as reliably as with the old skill.
    • Everything the skill and the help show now works. The old skill taught commands that failed and flags that did nothing; now every change is checked so the skill and the CLI list the same commands. surf <command> --help covers every command and only lists flags that do something, with examples for the tricky ones such as form.fill. surf --find searches all of them, so surf --find thread finds surf use x thread, and a mistyped command suggests the right one (surf bookmarks → bookmark.list).
    • Commands that quietly did the wrong thing now do what they say: surf drag --from x,y --to x,y drags, surf click e5 --button right right-clicks instead of left-clicking, surf type "…" --submit presses Enter, and the browser shows the extension's real version instead of 2.6.0.

    Changed

    • A much smaller agent skill that only names commands that work - The Surf skill an agent reads before driving the browser went from about 10,600 tokens to about 2,100, so more of the agent's context is left for the pages it reads. It keeps the rules, the everyday commands and examples of the tricky ones, and names every other command so the agent can look up its flags with surf <command> --help. Details on semantic models, ChatGPT consults, remote browsers, workflows and troubleshooting moved to skills/surf/references/, which the agent opens only when a task needs them. If you install the skill by hand, copy or symlink the whole skills/surf folder so those files come along. Commands and flags the old skill showed that never worked, such as key.repeat, bookmarks and cookie list --domain, are gone, and the skill is checked against the CLI on every change so it can't drift again. See [#399](https://github.com/nicobailon/surf
  5. v2.23.02026-10-24 · 2026-10-24 15:21 UTC
    查看更新说明

    Surf can now pull a whole feed out of a site in one command instead of scrolling and reading the page. An X thread comes back as clean Markdown in about 5 seconds through your logged-in browser, and a short JSON recipe does the same for most other sites. Agents can also read exact values off a page, like an order total or a delivery date, without reading the whole page. Semantic commands now run on Cloudflare's models as well as TypeSafe's Jev, and Cloudflare's Clef can find icon-only buttons. A few rough edges are gone too: uploads work on sites that hide their file input, and typing keeps punctuation.

    Highlights

    • Get a whole X thread as clean Markdown in about 5 seconds through your logged-in browser, with no scrolling and no API key: surf use x thread --url <post-url> --format md.
    • Most other sites work the same way with a short JSON recipe that you or your agent write once. After that, one command pulls the whole feed: a Bluesky feed came back as 83 posts over 3 pages in about 4 seconds. docs/threads-and-feeds.md shows how to write one.
    • Read exact values off a page with surf semantic.extract --field total:number:"Order total". Cloudflare's Clairic finds each value by meaning and returns it as a real number or date, or null when it isn't on the page. surf do workflows can read and check these values too.
    • Agents can ask Surf about a page in plain words, like "the save button" or "did it land in the cart?". TypeSafe's Jev stays the default; Cloudflare's Clef now works too, with your own Cloudflare account, and with --vision it found the right icon-only button in 30 of 30 test runs, against 0 of 30 without it.
    • Fewer dead ends: uploads work on sites that hide their file input, such as LinkedIn job applications, surf type no longer drops or scrambles punctuation, and surf search and surf locate.role point at the element you meant.

    Need to know

    If you set SURF_JEV_MODEL, rename it to SURF_SEMANTIC_MODEL. Surf no longer reads the old name, and the value must be jev-1.13.0, clef or clef-flash.

    Full changelog

    Highlights

    • Get a whole X thread as clean Markdown in about 5 seconds through your logged-in browser, with no scrolling and no API key: surf use x thread --url <post-url> --format md. Long posts come in full, with images and quoted posts.
    • Most other sites work the same way with a short JSON recipe that you or your agent write once. After that, one command pulls the whole feed: a Bluesky feed came back as 83 posts over 3 pages in about 4 seconds. docs/threads-and-feeds.md shows how to write one.
    • Agents can ask Surf about a page in plain words, like "the save button" or "did it land in the cart?", and a small, fast decision model picks the element or answers. TypeSafe's Jev stays the default. Cloudflare's Clef now works too, with your own Cloudflare account, and with --vision it finds icon-only buttons: the right icon in 30 of 30 test runs, against 0 of 30 without it.
    • Read exact values off a page, like an order total or a delivery date, without the agent reading the whole page: surf semantic.extract --field total:number:"Order total". Cloudflare's Clairic finds each value by meaning and returns it as a real number or date, or null when it isn't on the page. surf do workflows can read and check these values too.
    • Fewer dead ends: uploads work on sites that hide their file input, such as LinkedIn job applications, surf type no longer drops or scrambles punctuation, and surf search and surf locate.role point at the element you meant.

    Added

    • Choose the semantic model - semantic.find, semantic.filter, semantic.verify and semantic.act take --model jev-1.13.0|clef|clef-flash. SURF_SEMANTIC_MODEL sets the default, and it is the only way to pick the model for semantic surf do steps.

用户点评

登录用户的真实使用记录,可按版本和硬件筛选
共 0 条

    还没有人点评。第一个说出真实体验的人最有价值。

    README 原文

    未翻译

    Surf

    The CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.

    npm version License: MIT Platform

    See CHANGELOG for current release notes.

    surf go "https://example.com"
    surf read
    surf click e5
    surf snap
    

    Why Surf

    Browser automation for AI agents is harder than it looks. Most tools require complex setup, tie you to specific AI providers, or break on real-world pages.

    Surf takes a different approach:

    Agent-Agnostic - Pure CLI commands over Unix socket. Works with Claude Code, GPT, Gemini, Cursor, custom agents, shell scripts - anything that can run commands.

    Zero Config - Install the extension, run commands. No MCP servers to configure, no relay processes, no subscriptions.

    Battle-Tested - Built by reverse-engineering production browser extensions and methodically working through agent-hostile pages like Discord settings. Falls back gracefully when CDP fails.

    Smart Defaults - Screenshots auto-resize to 1200px (saves tokens). Actions auto-capture screenshots (saves round-trips). Errors on restricted pages warn instead of fail.

    AI Without API Keys - Query ChatGPT, Gemini, Perplexity, and Grok using your existing browser logins. No API keys needed.

    Network Capture - Automatically logs all network requests while active. Filter, search, and replay API calls without manually setting up request interception.

    Comparison

    FeatureSurfManusClaude ExtensionDevTools MCPdev-browser
    Agent-agnosticYesNo (Manus only)No (Claude only)PartialNo (Claude skill)
    Zero configYesNo (subscription)No (subscription)No (MCP setup)No (relay server)
    Self-hosted (local or Tailnet)YesNo (cloud)PartialYesPartial
    CLI interfaceYesNoNoNoNo
    FreeYesNoNoYesYes
    AI via browser cookiesYesNoNoNoNo
    Whole threads and feeds as records, via the site's own APIYesNoNoPartial (reads responses)No

    Installation

    Quick Start

    # 1. Install globally
    npm install -g surf-cli
    
    # 2. Install the native host; this prints the extension folder to load
    surf install
    
    # 3. Load the extension in Chrome
    #    - Open chrome://extensions
    #    - Enable "Developer mode"
    #    - Click "Load unpacked" and choose the folder `surf install` printed
    #      (`surf extension-path` prints it too)
    
    # 4. Restart Chrome and test
    surf tab.list
    
    # Later: update the CLI, native host, extension and skill together
    surf update
    

    Every Surf build has the same extension ID (oaclimdkkglbnpekeignmjenkodkaaka), so surf install needs no ID. If you load a fork or another build with a different ID, pass it: surf install <extension-id>.

    surf install also links ~/.claude/skills/surf and ~/.pi/agent/skills/surf (where those folders exist) to this package's skills/surf. It never replaces a skill folder you made yourself.

    surf update reinstalls surf (or rebuilds a git checkout), refreshes the native host and skill links, reloads the extension, and prints the versions before and after. The first time, reload Surf once by hand in chrome://extensions. Keep Developer mode on in chrome://extensions: with it off, Chrome disables an unpacked extension that reloads itself. The reload restarts Surf for every session, so commands running in other sessions fail.

    Multi-Browser Support

    surf install                    # Chrome (default)
    surf install --browser brave    # Brave
    surf install --browser helium   # Helium
    surf install --browser all      # All supported browsers
    surf install --target linux     # WSLg/Linux browser from WSL2
    

    Supported: chrome, chromium, brave, edge, arc, helium

    Start the browser automatically To have Surf start the browser when it isn't running, add this to surf.json (in the current directory or your home directory):

    { "browser": { "autostart": true, "app": "chrome" } }
    

    Most commands that find no running browser then start it, wait for the Surf extension to connect (up to 30 seconds, or timeoutMs), and run; streams, record and compound commands such as extract don't start it. surf go <url> opens the browser on that page. app is one of the browsers above (default chrome); on Linux Surf runs the browser's usual binary from PATH. To start it some other way, give the full command instead, for example "command": ["/usr/bin/chromium", "--profile-directory=Work"]; Surf adds the URL at the end. Remote mode never starts a browser.

    WSL2 with Windows Chrome When you run surf install inside WSL2, Surf detects WSL2 and installs a Windows-side native messaging manifest for Windows Chrome/Brave/Edge by default. The generated Windows wrapper launches the WSL2 host with wsl.exe, so surf commands run inside WSL2 still connect to the WSL socket.

    If you use a Linux browser inside WSLg instead, install with:

    surf install --target linux
    

    Restart Windows Chrome after installing. If the extension reports Access to the specified native messaging host is forbidden, rerun surf install from the same WSL distro and confirm chrome://extensions shows Surf's ID as oaclimdkkglbnpekeignmjenkodkaaka (or pass the ID it shows).

    Package Manager Installs (Nix, Homebrew, etc.) If surf is installed via a package manager that stores binaries in non-standard locations, set these environment variables before running surf install:

    export SURF_NODE_PATH=/path/to/node
    export SURF_HOST_PATH=/path/to/native/host.cjs
    export SURF_EXTENSION_PATH=/path/to/extension/dist
    

    See Environment Variables for details.

    Uninstall

    surf uninstall                  # Chrome only
    surf uninstall --all            # All browsers + wrapper files
    surf uninstall --target linux   # Remove WSLg/Linux-browser config from WSL2
    

    Remote Surf over Tailscale

    Remote Surf runs the browser and native host on one Tailnet machine while the CLI runs on another. The listener is available only while the browser extension's native-messaging connection is alive. Tailnet reachability is not authorization: every remote client also needs its own Surf credential.

    On the browser host, authorize a client before installing the listener:

    surf remote authorize agent-macbook --output ~/agent-macbook.surf-credential.json
    surf remote list
    surf install --listen 100.101.102.103:4321
    

    authorize creates a mode-0600 credential containing the client's Ed25519 private identity and the pinned host identity. Move it to that client through an existing secure channel, then remove the generated copy from the host if it is no longer needed there. The host keeps only the client's public identity in ~/.surf/remote/remote-clients.json.

    From the authorized client:

    surf --remote 100.101.102.103:4321 \
      --remote-credential ~/.config/surf/agent-macbook.json \
      tab.list
    
    # Environment equivalent
    SURF_REMOTE=100.101.102.103:4321 \
    SURF_REMOTE_CREDENTIAL=~/.config/surf/agent-macbook.json \
      surf tab.list
    

    For a TLS-terminating reverse proxy in front of the native host's existing clear-TCP listener, opt in on the client:

    surf --remote surf.example.com:443 --remote-tls \
      --remote-credential ~/.config/surf/agent-macbook.json tab.list
    
    # Private CA and an IP destination with a DNS certificate identity
    surf --remote 100.101.102.103:443 --remote-tls \
      --remote-tls-ca ~/.config/surf/private-ca.pem \
      --remote-tls-server-name surf.example.com \
      --remote-credential ~/.config/surf/agent-macbook.json tab.list
    

    --remote-tls-ca replaces Node's system roots rather than adding to them. DNS endpoints use their hostname for SNI and certificate validation; IP endpoints omit SNI and validate the certificate's IP SAN unless --remote-tls-server-name supplies a DNS identity. TLS certificate validation finishes before the mandatory Ed25519 authentication. Surf has no insecure mode, downgrade, or plaintext retry. SURF_REMOTE_TLS=1 is the only accepted environment spelling; unset it to disable TLS. There is no CLI negation for env-enabled TLS. CLI values override SURF_REMOTE_TLS_CA and SURF_REMOTE_TLS_SERVER_NAME independently. SURF_LISTEN remains a plaintext listener behind the reverse proxy; Surf does not terminate TLS on the browser host.

    Surf performs mutual Ed25519 challenge-response with fresh nonces and checks authorization throughout the connection. A credential grants the same browser and host-file authority as a trusted local Surf user. Give each client its own credential, do not share it, and revoke it immediately if the client or file is lost:

    surf remote revoke agent-macbook
    surf remote list
    

    --remote <host>:<port> takes precedence over SURF_REMOTE; --remote-credential takes precedence over SURF_REMOTE_CREDENTIAL. A selected remote endpoint overrides SURF_SOCKET and the default local socket. Local and remote requests share the same host scheduler: each tab has a FIFO lane, different tabs may execute concurrently, and browser-wide writers are exclusive. Disconnects and timeouts abort queued or in-flight work and retain admission until request-owned cleanup drains or the hard deadline is reached. Browser side effects that already completed are not rolled back.

    surf install --listen persists the explicit Tailnet address in the native-host wrapper. Re-run surf install without --listen to remove it. The address must be a Tailscale IPv4 or IPv6 address with a port; Surf does not bind every interface. Remote listeners currently require a POSIX browser host and are not supported by Windows native-host wrappers.

    For advanced local-only sharing, surf install --socket-mode 660 --socket-group <group> persists an opt-in group-owned socket. The default remains mode 600; mode 660 gives every account in that group full Surf authority, so use a dedicated narrow group. Re-run surf install without these flags to clear persisted socket settings. Remote Surf credentials remain the revocable per-client alternative.

    Keep Tailscale policy restrictions as defense in depth. For example:

    {
      "acls": [
        {
          "action": "accept",
          "src": ["tag:surf-agent"],
          "dst": ["tag:surf-browser:4321"]
        }
      ]
    }
    

    Adapt tags and ports to your Tailnet. Surf authentication does not replace Tailnet policy. Optional outbound remote TLS protects the client-to-proxy connection; Surf does not add an SSH tunnel or a TLS listener.

    Operations and troubleshooting

    tailscale status
    tailscale ping 100.101.102.103
    surf doctor --remote 100.101.102.103:4321 \
      --remote-credential ~/.config/surf/agent-macbook.json
    

    Use tailscale status and tailscale ping to confirm reachability, then use doctor to verify endpoint selection and authentication.

    Remote filesystem and transfer semantics

    Unprefixed paths and local: paths refer to the client. Only remote:/absolute/path refers directly to the browser host. For example:

    surf --remote "$SURF_REMOTE" --remote-credential "$SURF_REMOTE_CREDENTIAL" \
      upload --ref e5 --files ./client-file.pdf
    surf --remote "$SURF_REMOTE" --remote-credential "$SURF_REMOTE_CREDENTIAL" \
      screenshot --output local:./shot.png
    surf --remote "$SURF_REMOTE" --remote-credential "$SURF_REMOTE_CREDENTIAL" \
      network.export --output remote:/var/tmp/network.har --har
    

    Client-local inputs are staged privately on the host and removed after the request. Client-local outputs are downloaded with size/hash verification and atomic destination replacement. surf js --file and perf-audit --output are handled by the client itself. network.export defaults to a generated client-local .json, .jsonl, or .har path. Gemini edits default to client-local edited.png. Successful remote actions transfer their automatic screenshot to a generated client-local path; --auto-capture on failure remains a separate screenshot and console diagnostic.

    The remote single-file boundary supports one upload file, one ChatGPT attachment, or one Gemini attachment/edit input, plus one screenshot, network export, or Gemini image output. Transfers are limited to 256 MiB per file, 512 MiB and 32 files per connection, with 256 KiB decoded chunks. Remote record, video, aistudio.build, smoke screenshot directories, directory transfer, and multi-file inputs are intentionally rejected. A remote: path bypasses transfer and gives the trusted client direct authority over that absolute host path.

    Development Setup

    git clone https://github.com/nicobailon/surf-cli.git
    cd surf-cli
    npm install
    npm run build
    # Then load dist/ as unpacked extension
    

    Usage

    surf <command> [args] [options]
    surf --help                    # Basic help
    surf --llm-context             # Compact reference for AI agents
    surf --help-full               # All 50+ commands
    surf <command> --help          # Command details
    surf --find <query>            # Search commands
    

    Navigation

    surf go "https://example.com"
    surf back
    surf forward
    surf tab.reload --hard
    

    Reading Pages

    surf read                           # Accessibility tree + visible text content
    surf read --all                     # Every visible element, including offscreen
    surf read --include-hidden          # Also hidden and aria-hidden elements
    surf read --no-text                 # Accessibility tree only (no text)
    surf read --depth 3                 # Limit tree depth (smaller output)
    surf read --compact                 # Remove empty structural elements
    surf read --depth 3 --compact       # Both (60% smaller output)
    surf read --max-bytes 2000          # Cap the tree and visible text at 2000 bytes
    surf read --summary                 # Title, headings, control counts per region, open dialogs (no refs)
    surf page.text                      # Visible text only
    surf page.html                      # Rendered document HTML
    surf page.html --strip-scripts > artifact.html # Save a safe static Claude artifact
    surf page.save --selector "#artifact" --strip-scripts --output artifact.html # Save one rendered element
    surf page.state                     # Modals, loading state, scroll position
    

    Use surf page.html --strip-scripts after the page loads when you need a static export of a Claude artifact or other rendered DOM. Use --selector <css> to export one element.

    Trees over 50,000 bytes and page text over 50,000 characters, or either over --max-bytes, end with a note giving the shown and total bytes and the path of a private file with the full content. Remote clients get the note without a path; raise the limit with --max-bytes, or narrow with --ref or --depth.

    Element refs (e1, e2, e3...) are stable identifiers from the accessibility tree - semantic, predictable, and resilient to DOM changes.

    Semantic Locators

    Find and interact with elements by role, text, or label - no refs or selectors needed:

    # By ARIA role
    surf locate.role button --name "Submit"           # Find button
    surf locate.role button --name "Submit" --action click  # Find and click
    surf locate.role textbox --name "Search" --action fill --value "hello"  # Find and fill
    surf locate.role link --all                       # List all links
    
    # By text content  
    surf locate.text "Sign In" --action click         # Click element with text
    surf locate.text "Accept" --exact                 # Exact match only
    
    # By form label
    surf locate.label "Email" --action fill --value "[email protected]"
    

    An exact name match wins over partial ones, so --name "Save" picks a "Save" button over "Save draft". With --action, if more than one element still matches, Surf does nothing and lists each match with its ref, so you can act on the one you mean with surf click <ref>.

    Iframe Support

    Work with content inside iframes:

    surf frame.list                     # List all frames
    surf frame.switch --index 0         # Switch to first iframe
    surf frame.switch --name "payment"  # Switch by frame name
    surf frame.switch --selector "#checkout-frame"  # Switch by CSS selector
    
    # Page and interaction commands now target the iframe
    surf read                           # Read iframe content
    surf page.text                      # Read an iframe's visible text
    surf page.state                     # Inspect iframe UI state
    surf click e5                       # Click in iframe
    surf type "4242" --into "#card-number"
    surf locate.role button --name "Pay" --action click
    
    surf frame.main                     # Return to main page
    
    CommandAfter frame.switch
    read, page.text, page.state, click, type, select, locate.*Use the selected frame, including reachable out-of-process iframes.
    jsFails with UNSUPPORTED_FRAME_EXECUTION instead of running in the main frame. Run frame.main first.
    frame.js --id <id>Ignores frame.switch and takes a CDP frame ID, so it cannot reach frames missing from the CDP tree.
    Tab, window, browser, and network commandsUnaffected.

    When a selector never matches, frame.diagnose shows the three frame views side by side (DOM <iframe> elements, the extension's frames with content-script reachability, and the CDP frame tree) and explains the mismatches: srcdoc/about:blank frames (matched to their CDP frame by name/id), sandboxes without allow-scripts, cross-origin frames, out-of-process frames that the CDP tree does not list (frame.js cannot reach them; frame.switch and page.read can when the content script answers), and frames still loading. The DOM inventory walks open shadow roots, so frames rendered by custom elements are listed with their shadowHost path. The text report abbreviates long frame URLs; --json keeps them whole.

    surf frame.diagnose                 # Human-readable report with warnings
    surf frame.diagnose --json          # Full inventories
    

    Interaction

    surf click e5                       # Click by element ref
    surf click --selector ".btn"        # Click by CSS selector
    surf click 100 200                  # Click by coordinates
    surf type "hello" --submit          # Type at the current focus with CDP events
    surf type "[email protected]" --ref e12  # Fill an element from page.read
    surf type "hello" --into "#message"     # Fill a selector in the active frame
    surf key Escape                     # Press key
    surf scroll down 800                # Scroll down 800px
    surf scroll bottom                  # Scroll to bottom
    surf scroll.bottom                  # Dot command form also works
    

    Forms

    Select options in dropdown menus:

    surf select e5 "US"                         # Select by value
    surf select "#country" "US"                 # Select by CSS selector
    surf select e5 "opt1" "opt2"                # Multi-select
    surf select e5 --by label "United States"   # Select by visible text
    surf select e5 --by index 0                 # Select first option
    

    Set a checkbox, switch or radio to a state (nothing is clicked if it is already there):

    surf check e6                               # Checked afterwards, or the command fails
    surf uncheck e6                             # Unchecked afterwards
    

    Upload files:

    surf upload --ref e5 --files ./report.pdf      # The file input's ref
    surf upload --ref e640 --files ./resume.pdf    # Or the button that opens the file picker
    

    With a button ref, surf clicks it and answers the native file chooser, so a hidden input[type=file] doesn't need a ref.

    Element Inspection

    Get computed styles from elements:

    surf element.styles e5              # Get styles by ref
    surf element.styles ".header"       # Get styles by CSS selector (can return multiple)
    

    Returns font, color, background, border, padding, and bounding box for design debugging.

    Screenshots

    Screenshots auto-save to /tmp by default (optimized for AI agents):

    surf screenshot                             # Auto-saves to /tmp/surf-snap-*.png
    surf screenshot --output /tmp/shot.png      # Save to specific path
    surf screenshot --full --output /tmp/hd.png # Full resolution (skip resize)
    surf screenshot --annotate                  # With element labels
    surf screenshot --fullpage                  # Entire page
    surf screenshot --full-page /tmp/full.png   # Entire page, save to path
    surf screenshot --no-save                   # Return base64 + ID only (no file)
    surf snap                                   # Alias for screenshot
    

    To disable auto-save globally, set autoSaveScreenshots: false in surf.json.

    Actions like click, type, and scroll automatically capture a screenshot after execution - no extra command needed. The command replies as soon as the action finishes with Screenshot (pending): <path>, and the file appears at that path shortly after. The next command on the same tab waits until it is written. Remote clients receive the file before the reply.

    These actions and select also report what changed on the page, waiting up to 2000ms for it to settle. Set settleMs in surf.json or --settle <ms> per command (max 30000), or turn it off with --no-diff. When the report is cut short, its last line names a small follow-up instead of a full read: surf locate.role <role> --name "<text>" or surf read --ref <ref> --all --no-text.

    Tabs

    surf tab.list
    surf tab.new "https://example.com"
    surf tab.switch 123
    surf tab.close 123
    surf tab.move 123 --to-window 456   # Move one tab; use --ids 123,124 for several
    surf tab.name "dashboard"           # Name current tab
    surf tab.switch "dashboard"         # Switch by name
    surf tab.group --name "Work" --color blue
    

    Browser Sessions and Concurrent Agents

    Give every independent agent a durable Surf session before its first browser command. session.ensure is idempotent: it creates a missing session, reuses a live one, and reopens a stale or closed binding.

    # First command rule for every independent agent shell
    export SURF_SESSION="$(basename "$PWD" | sed 's/[^A-Za-z0-9._-]/-/g')"
    surf session.ensure "$SURF_SESSION" about:blank
    
    # All later tab-scoped commands use that session automatically
    surf go "https://example.com"
    surf read
    surf click e5
    

    Use a distinct worktree/directory name per agent. When several agents share one directory, append a stable agent identifier instead of reusing the same SURF_SESSION value.

    A session owns one explicit Chrome tab. New sessions use a separate unfocused normal window by default, so Chrome focus changes cannot retarget another agent's commands.

    surf session.new research "https://example.com"   # separate unfocused window
    surf session.ensure research about:blank           # safe to run repeatedly
    surf session.new scout about:blank --tab            # inactive tab instead
    
    surf --session research read                        # explicit selector
    SURF_SESSION=research surf screenshot               # environment selector
    
    surf session.list --refresh                         # all bindings + queue state
    surf session.info research --refresh                # target and scheduler details
    surf session.close research                         # closes Surf-created target
    surf session.cleanup --idle-after 1h --dry-run      # preview forgotten sessions
    surf session.cleanup --idle-after 1h                 # remove idle bindings
    surf session.rebind research --tab-id 789            # adopt an existing tab
    surf session.reopen research                         # recreate from last URL
    

    session.cleanup is an explicit, one-shot cleanup operation; --idle-after is required. It inspects current bindings first, removes gone or stale records, and removes live inactive bindings older than the threshold. Only Surf-created targets are closed. Adopted targets remain open while their session binding is removed. Use --dry-run to report the exact bindings and target actions without changing the store or browser.

    Commands for the same session tab run FIFO. Commands for different session tabs can overlap. Browser-wide mutations—such as creating, moving, closing, or focusing tabs/windows and writing cookies—wait for active tab lanes to drain. Add --no-wait to return tab_busy or browser_busy immediately instead of queueing.

    Recovery errors print an exact command that can be copied directly:

    Error: The tab for session research is gone.
    Recovery: surf session.reopen research
    

    session.info distinguishes work queued on the session's own tab, activity on other tabs, and an active or waiting browser-wide writer. Browser-login provider commands such as surf chatgpt, surf gemini, and surf oracle ask print a warning before taking exclusive browser access, so a queued provider flow is not mistaken for a hung command.

    Sessions share the same Chrome profile. Cookies, authentication, same-origin storage, downloads, history, bookmarks, and other profile state are shared. For hard isolation, use separate browser profiles/instances with separate native hosts and SURF_SOCKET values.

    Explicit Tabs and Windows

    Session targeting is the recommended coordination mechanism. Explicit IDs and named tabs remain available for one-off work:

    surf window.new "https://example.com"
    surf read --tab-id 789
    surf click e5 --window-id 123456
    surf tab.name dashboard --tab-id 789
    surf tab.switch dashboard
    

    Device Emulation

    Test responsive designs and mobile layouts:

    surf emulate.device --list                    # Show available devices
    surf emulate.device "iPhone 14"               # Emulate iPhone 14
    surf emulate.device "Pixel 7"                 # Emulate Pixel 7
    surf emulate.device reset                     # Return to desktop
    
    # Custom viewport
    surf emulate.viewport --width 375 --height 812
    surf emulate.viewport --width 1920 --height 1080 --scale 2
    
    # Touch emulation
    surf emulate.touch                            # Enable touch
    surf emulate.touch --enabled false            # Disable touch
    

    Available devices: iPhone 12-14 (Pro/Max), iPhone SE, iPad (Pro/Mini), Pixel 5-7 (Pro), Galaxy S21-S23, Galaxy Tab S7, Nest Hub (Max).

    Animation Recording

    Capture a screenshot burst and assemble it into an animated GIF with ImageMagick:

    surf record --duration 2000 --fps 10 --output /tmp/anim.gif
    surf record --trigger "click:#btn" --output /tmp/click.gif
    surf record --rect 0,200,1440,800 --output /tmp/region.gif
    

    record defaults to 2000ms at 10fps and writes to /tmp/surf-record-*.gif when no output is provided. --duration is capped at 10000ms and --fps is capped at 30. --trigger supports click:<selector>, scroll:up|down|left|right|top|bottom, and scroll:<selector> to scroll a container to the bottom before capture. --rect crops the GIF using x,y,width,height. ImageMagick must be available as magick or convert.

    Local Video Recording

    Capture a local tab as WebM/VP9 with ffmpeg:

    surf video start ./demo.webm --fps 30
    surf video status
    surf video stop
    surf video restart ./take-2.webm --fps 60
    

    --fps defaults to 30 and is capped at 60. Video recording is local-only and requires ffmpeg on PATH.

    Animation Audit

    Sample matching elements over time and return a bounded JSON timeline for agent inspection:

    surf animate-audit --selector ".thing" --duration 2000 --fps 10
    

    The command captures rect, opacity, transform, visibility, display, and a short text snippet for up to 25 matching elements per sample. --selector is required. --duration defaults to 2000ms and is capped at 10000ms; --fps defaults to 10 and is capped at 30. This command returns JSON only and does not record GIF/video output.

    Performance Audit

    Capture layout shift, long animation frame, event timing, long task, and paint entries during a short window:

    surf perf-audit --duration 3000 --trigger "click:.cta" --output /tmp/perf.json
    surf perf-audit --duration 1000 --json
    

    perf-audit defaults to 3000ms and is capped at 10000ms. --trigger supports the same click:<selector> and scroll:<target> forms as record. --output writes the JSON snapshot to disk.

    Performance Tracing

    Capture performance metrics and traces:

    surf perf.metrics                   # Current performance metrics
    surf perf.start                     # Start tracing
    surf perf.stop                      # Stop and get trace data
    

    AI Queries (No API Keys)

    Query AI models using your browser's logged-in session:

    # ChatGPT
    surf chatgpt "explain this code"
    surf chatgpt "summarize" --with-page     # Include page context
    surf chatgpt "analyze" --model gpt-5.5   # Specify model
    surf chatgpt "review" --file code.ts     # Attach file
    
    # Gemini
    surf gemini "explain quantum computing"
    surf gemini "summarize" --with-page                           # Include page context
    surf gemini "analyze" --file data.csv                         # Attach file
    surf gemini "a robot surfing" --generate-image /tmp/robot.png # Generate image
    surf gemini "add sunglasses" --edit-image photo.jpg --output out.jpg
    surf gemini "summarize" --youtube "https://youtube.com/..."   # YouTube analysis
    surf gemini "hello" --model gemini-3.5-flash                  # Model selection
    
    # Perplexity
    surf perplexity "what is quantum computing"
    surf perplexity "explain this page" --with-page               # Include page context
    surf perplexity "deep dive" --mode research                   # Research mode (Pro)
    surf perplexity "latest news" --model sonar                   # Model selection (Pro)
    
    # Grok (queries x.com/i/grok using your X.com login)
    surf grok "what are the latest AI agent trends on X"          # Search X posts
    surf grok "analyze @username recent activity"                 # Profile analysis
    surf grok "summarize this page" --with-page                   # Include page context
    surf grok "find viral AI posts" --deep-search                 # DeepSearch mode
    surf grok "quick question" --model fast                       # Models: auto, fast, expert, grok-4.20-beta
    surf grok --validate                                          # Check UI and available models
    surf grok --validate --save-models                            # Save discovered models to settings
    
    # AI Studio (queries aistudio.google.com using your Google login)
    surf aistudio "explain quantum computing"
    surf aistudio "redteam this" --with-page                      # Include page context
    surf aistudio "quick answer" --model gemini-3-flash-preview   # Model selection
    
    # Kimi (queries kimi.com - Moonshot K-series - using your browser login)
    surf kimi "explain quantum computing"
    surf kimi "summarize" --with-page                             # Include page context
    surf kimi "quick answer" --model thinking                     # Models: instant (default), thinking, high
    surf kimi --validate                                           # Check kimi.com UI and available models
    
    # AI Studio App Builder (generates full web apps from a prompt)
    surf aistudio.build "build a portfolio site"
    surf aistudio.build "todo app" --model gemini-3.1-pro-preview # Model override
    surf aistudio.build "crm dashboard" --output ./out            # Extract zip to directory
    surf aistudio.build "game" --keep-open --timeout 600          # Keep tab open, 10min timeout
    
    Oracle

    Use surf oracle for a durable, local ChatGPT consult instead of a quick surf chatgpt one-shot. It persists jobs by conversation URL, supports repeatable file-context globs, one direct local attachment with --file, and verifies requested model and reasoning effort before submission. Add --github when the consult needs the ChatGPT Chat tab and connected GitHub tool. ChatGPT model aliases include gpt-6-astra, latest, gpt-5.6-sol, and gpt-5.5; latest is an explicit floating choice, while gpt-6-astra must read back as model 6 before submission. Accepted efforts are instant, medium, high, xhigh/extra-high, and pro. Use --model gpt-6-astra --effort pro for GPT-6 Astra with Pro effort.

    ChatGPT can hide the model version at lower effort settings. Use --model latest if you want floating model selection there; an explicit gpt-6-astra request fails closed when the UI cannot verify model 6.

    surf oracle ask "review this change" --files "src/**/*.ts" --file ./design.md --model gpt-5.5 --effort pro --github --detach --json
    surf oracle status <job-id> --json
    surf oracle result <job-id> --wait --json
    surf oracle follow <job-id> "challenge that recommendation" --file ./follow-up.md --github --detach --json
    

    Only one oracle job can be in flight. Sensitive filename patterns and gitignored context are blocked unless --allow-sensitive is explicit.

    Each AI tool uses your existing browser login - no API keys needed. Just be logged into the respective service in Chrome (chatgpt.com, gemini.google.com, perplexity.ai, x.com, or aistudio.google.com).

    Grok troubleshooting: If queries fail, run surf grok --validate to check if the UI structure changed. Use --save-models to update the model cache in surf.json. Default model is fast.

    Waiting

    surf wait 2                         # Wait 2 seconds
    surf wait.element ".loaded"         # Wait for element
    surf wait.network                   # Wait for network idle
    surf wait.url "/dashboard"          # Wait for URL pattern
    surf wait.ready --selector ".results"   # Wait for content, fail fast on a bounce
    surf page.readiness --json          # Classify the page once
    

    wait.ready polls with a bounded budget and reports a typed state instead of timing out silently: ready, empty (the page showed its own no-results message, --empty-text), or one of the negative states login, challenge (anti-bot interstitial), not-found, error. A negative state exits non-zero with codes page_login, page_challenge, page_not_found, page_error; page_timeout reports the last observed state. Pass --accept login to return a state to the caller instead. Detection uses visible UI state (a rendered password field, a login-looking route, the page's own wording, --url-prefix bounces), never site-specific selectors.

    surf wait.ready --url-prefix "https://app.example.com/" --empty-text "No results"
    surf wait.ready --accept login --json   # {"state":"login","evidence":[...]} instead of an error
    

    Extracting structured data

    surf extract composes existing client-side tools: it opens an owned tab, waits for explicit readiness, runs a page script, validates its JSON result, and closes the tab. It prints concise Markdown by default or structured JSON with --json. Return an array, or an object containing a conventional row key such as rows, items, or results; use --rows <key> for another key.

    surf extract "https://example.com/list" --file rows.js --ready-selector ".item"
    surf extract "https://example.com/search" --file rows.js --options '{"limit":20}' --empty-text "No results" --json
    surf extract --tab-id 42 --code 'return [...document.querySelectorAll("h2")].map(h => ({title: h.textContent}))'
    

    Owned-tab failures always attempt cleanup. Zero rows retry unless --allow-empty is set or --empty-text identifies the page's accepted empty state. Fresh-tab retries are bounded (--retry, default 1, maximum 5) and are limited to readiness timeouts, zero rows, and lost tab/execution-context failures. Login, challenge, not-found, page-error, script/output, and cleanup failures do not retry. --tab-id and --session target an existing page and never retry or close it; --keep-tab preserves a successfully owned tab.

    Extract is intended for read-only or otherwise idempotent caller scripts. JavaScript is not inherently read-only: a retry can replay the script, so avoid mutations or make them idempotent.

    Other

    js and frame.js accept --options '{"limit": 20}' with inline code or --file. This defines SURF_OPTIONS by parsing the JSON and freezing the result; use an explicit return for the script's result. The freeze is shallow. Invalid JSON and non-object values are rejected before sending a request; --options '' defines an empty object. Without --options, code is unchanged.

    surf js "return document.title"     # Execute JavaScript
    surf js "piHelpers.setValue(document.querySelector('#q'), 'hello')"  # Native value setter + input/change events
    surf js --file script.js --options '{"limit": 20}'   # Script reads SURF_OPTIONS.limit
    surf record --duration 2000 --fps 10 --output /tmp/anim.gif      # Animated GIF capture
    surf animate-audit --selector ".thing" --duration 2000 --fps 10  # JSON animation timeline
    surf perf-audit --duration 3000 --output /tmp/perf.json           # PerformanceObserver snapshot
    surf search "login"                 # Find text in page
    surf cookie list                    # List cookies
    surf zoom 1.5                       # Set zoom to 150%
    surf console                        # Read console messages
    surf network                        # Read network requests
    

    Network Capture

    Surf automatically captures all network requests while active. No explicit start needed.

    # Overview (token-efficient for LLMs)
    surf network                          # Recent requests, compact table
    surf network --urls                   # Just URLs (minimal output)
    surf network --format curl            # As curl commands
    
    # Filtering
    surf network --origin api.github.com  # Filter by origin/domain
    surf network --method POST            # Only POST requests
    surf network --type json              # Only JSON responses
    surf network --status 4xx,5xx         # Only errors
    surf network --since 5m               # Last 5 minutes
    surf network --exclude-static         # Skip images/fonts/css/js
    surf network -vv --body-mode text      # Full entries with capped text bodies
    
    # Drill down
    surf network.get r_001                # Full request/response details
    surf network.body r_001               # Response body (for piping to jq)
    surf network.curl r_001               # Generate curl command
    surf network.origins                  # List captured domains
    
    # Management
    surf network.clear                    # Clear captured data
    surf network.stats                    # Capture statistics
    surf network.export --har --output ./trace.har
    

    Response bodies are fetched at Network.loadingFinished when capture is enabled. --body-mode none|text|all, --per-body-bytes, and --total-body-bytes control content and caps; exports include completeness metadata.

    Storage location: ~/.surf/state/network/ (override with SURF_NETWORK_PATH in the native host environment). Surf creates private 0700 directories and 0600 files and rejects symlink targets. Auto-cleanup: 24 hours TTL, 200MB max.

    Workflows

    Execute multi-step browser automation as a single command:

    # Inline workflow (pipe-separated)
    surf do 'go "https://example.com" | read | click e5 | screenshot'
    
    # Multi-step login flow
    surf do 'go "https://example.com/login" | type "[email protected]" --selector "#email" | type "pass" --selector "#password" | click --selector "button[type=submit]"'
    
    # From JSON file
    surf do --file workflow.json
    
    # Run named workflow with arguments
    surf do my-workflow --url "https://example.com" --max_items 10
    
    # Validate without executing
    surf do 'go "url" | read | click e5 | screenshot' --dry-run
    

    Why workflows? Instead of 6-8 separate CLI calls with LLM orchestration between each step, a workflow executes deterministically with smart auto-waits. Faster, cheaper, and more reliable.

    Options:

    • --file, -f - Load workflow from JSON file
    • --dry-run - Parse and validate without executing
    • --on-error stop|continue - Error handling (default: stop)
    • --step-delay <ms> - Delay between steps (default: 100, use 0 to disable)
    • --no-auto-wait - Disable automatic waits between steps
    • --json - Output structured JSON result
    • --allow-semantic - Opt in to bounded model decisions for a semantic workflow (model from SURF_SEMANTIC_MODEL, default jev-1.13.0; extract steps always use clairic with Cloudflare credentials)
    • --allow-write - Additionally authorize declared fill, ensureChecked, and click steps
    • --inputs-stdin - Read one bounded JSON object of private local input slots from stdin
    • --<arg> <value> - Pass arguments to workflow (e.g., --url "...")

    Auto-waits: Commands that trigger page changes automatically wait for completion:

    • Navigation (go, back, forward) → waits for page load
    • Clicks, key presses, form fills → waits for DOM stability
    • Tab switches → waits for tab to load
    Workflow Files

    Workflows can be saved as JSON files and run by name. Place them in ~/.surf/workflows/ (user) or ./.surf/workflows/ (project).

    Basic format:

    {
      "name": "login-flow",
      "description": "Log into example.com",
      "args": {
        "email": { "required": true, "desc": "Login email" },
        "password": { "required": true, "desc": "Login password" }
      },
      "steps": [
        { "tool": "navigate", "args": { "url": "https://example.com/login" } },
        { "tool": "type", "args": { "text": "%{email}", "selector": "input[name=email]" } },
        { "tool": "type", "args": { "text": "%{password}", "selector": "input[name=password]" } },
        { "tool": "click", "args": { "selector": "button[type=submit]" } }
      ]
    }
    

    Step outputs - Capture results for use in later steps:

    {
      "steps": [
        { "tool": "js", "args": { "code": "return document.title" }, "as": "title" },
        { "tool": "js", "args": { "code": "return 'Page: ' + '%{title}'" } }
      ]
    }
    

    Loops - repeat for fixed iterations, each for arrays:

    {
      "steps": [
        { "tool": "js", "args": { "code": "return ['a', 'b', 'c']" }, "as": "items" },
        {
          "each": "%{items}",
          "as": "item",
          "steps": [
            { "tool": "js", "args": { "code": "return 'Processing: %{item}'" } }
          ]
        }
      ]
    }
    
    {
      "steps": [
        {
          "repeat": 5,
          "steps": [
            { "tool": "scroll", "args": { "direction": "down" } },
            { "tool": "wait", "args": { "duration": 500 } }
          ]
        }
      ]
    }
    

    Loop with exit condition - Stop early when condition is met:

    {
      "repeat": 20,
      "until": { "tool": "js", "args": { "code": "return !document.querySelector('.next-page')" } },
      "steps": [
        { "tool": "click", "args": { "selector": ".next-page" } },
        { "tool": "wait.load" }
      ]
    }
    
    Workflow Management
    # List available workflows
    surf workflow.list
    
    # Show workflow details and arguments
    surf workflow.info my-workflow
    
    # Validate workflow JSON
    surf workflow.validate ./my-workflow.json
    
    Bounded semantic workflow steps

    Semantic workflow files declare "semantic": { "version": 1 } and use only linear semantic.step operations: find, open, ensureChecked, fill, click, assert, and extract. Validate or dry-run them offline, then opt in explicitly:

    surf workflow.validate ./product.json
    surf do --file product.json --dry-run
    printf '%s' '{"quantity":"2"}' | SURF_SESSION=shopping surf do \
      --file product.json --inputs-stdin --allow-semantic --allow-write --json
    
    {
      "name": "configure-matching-product",
      "semantic": { "version": 1, "deadlineMs": 60000, "maxProviderCalls": 32 },
      "steps": [
        { "id": "find-product", "tool": "semantic.step", "as": "product", "args": {
          "op": "find", "target": { "query": "The in-stock blue insulated bottle", "role": "link" },
          "search": { "mode": "scroll", "maxObservations": 12 }
        } },
        { "id": "open-product", "tool": "semantic.step", "args": {
          "op": "open", "target": { "binding": "product" }
        } },
        { "id": "select-gift-wrap", "tool": "semantic.step", "args": {
          "op": "ensureChecked", "target": { "query": "Gift wrap", "role": "checkbox" },
          "checked": true
        } },
        { "id": "set-quantity", "tool": "semantic.step", "args": {
          "op": "fill", "target": { "query": "Quantity", "role": "spinbutton" },
          "input": "quantity"
        } },
        { "id": "add-to-cart", "tool": "semantic.step", "args": {
          "op": "click", "target": { "query": "Add to cart", "role": "button" },
          "expect": { "kind": "visible", "target": { "query": "Remove from cart", "role": "button" } }
        } },
        { "id": "verify-cart", "tool": "semantic.step", "args": {
          "op": "assert", "mode": "semantic",
          "claim": "The cart contains the selected product with gift wrap enabled",
          "bindings": ["product"]
        } }
      ]
    }
    

    This example includes every supported operation except extract (shown below) and the required claim for a semantic assert. Use it as a valid starting shape, then remove steps the task does not need.

    extract reads typed values off the current page in one step, the same way semantic.extract does. Its fields object uses the --fields-json grammar, and the step needs an as name:

    { "id": "read-order", "tool": "semantic.step", "as": "order", "args": {
      "op": "extract", "fields": {
        "total": { "type": "number", "decimals": 2, "instructions": "Order total including tax" },
        "delivered": { "type": "date", "instructions": "Estimated delivery date" }
      }
    } }
    

    It reads one observation of the visible page and makes one call to Cloudflare's Clairic model, which counts against maxProviderCalls. It needs Cloudflare credentials and doesn't need --allow-write. A workflow without decision steps, such as one that only extracts, needs no TypeSafe key. The step completes for any valid answer, including null and uncertain fields. The result appears under vars.<as> in surf do --json output, with the same shape as semantic.extract. Extracted values are never written to the workflow's state files or checkpoints, and only a value assert can read an extract result.

    To check an extracted value, add an assert step with "mode": "value". It names the extract step's as in binding and one of its fields in field, then gives equals, an input slot to compare with, or an inclusive min and/or max:

    { "id": "check-total", "tool": "semantic.step", "args": {
      "op": "assert", "mode": "value", "binding": "order", "field": "total", "input": "expectedTotal"
    } },
    { "id": "check-delivery", "tool": "semantic.step", "args": {
      "op": "assert", "mode": "value", "binding": "order", "field": "delivered",
      "min": "2026-10-01", "max": "2026-10-31"
    } }
    

    Run it with surf do --file check-order.json --expectedTotal 42.10 --allow-semantic --json. Numbers are compared after rounding both sides to the field's decimals, durations are in the field's unit, dates are YYYY-MM-DD, times HH:MM[:SS], datetimes YYYY-MM-DDTHH:MM[:SS], and text must match exactly. List fields can't be checked. A null or uncertain field always fails the check (value_null, value_uncertain), even when its guess would match; a value that doesn't match fails with assertion_mismatch. A value assert makes no browser request and no model call, and its failure message is only a reason code, never the extracted, expected or input value.

    open performs freshly validated same-origin HTTP(S) navigation; it never falls back to a click. Model-derived writes keep the model's write gate (0.95, or 0.85 with clef), dispatch at most once in a run, and require local/read-only verification. A stopped or uncertain step fails the workflow. Search reports bounded overlapping coverage and does not prove global ranking or absence outside that scope. Local input values are sent only to their browser fill/compare operation, not to the model provider, workflow variables, events, output, or checkpoints. A new run can repeat an external effect: Surf does not claim exactly-once server behavior or automatic resume. Each semantic step result's semantic block reports provider, model, providerCalls, and providerLatencyMs for the run so far. Workflows with an extract step also report Clairic's own totals under semantic.extraction.

    Supported commands: Ordinary workflows support all Surf commands. Semantic v1 workflows intentionally support only the seven closed operations above.

    Playbooks

    Use surf do for a direct sequence of browser commands. Use a playbook for a reusable site capability that can try a browser-session network request and fall back to a workflow when the endpoint drifts.

    surf playbook list
    surf pb show page
    surf pb ops page
    surf use page read --json
    
    # Write ops require explicit authorization and a durable duplicate-safety receipt.
    surf use <site> <write-op> --write --resource-id 123
    

    Playbook read ops can also use a trusted script strategy when fixed JSON steps are too rigid. Scripts require --allow-script at run time. Only run scripts from playbooks you trust. This is not a security sandbox.

    The script gets input, tools.run, tools.all, tools.ref/refs, emit, and console. Tool calls still use Surf workflow step behavior, including auto-waits unless autoWait is false.

    {
      "using": "script",
      "script": [
        "const page = await tools.run('page', { tool: 'page.text', args: {} });",
        "const clicked = await tools.all(input.selectors.map((selector) => ({ key: selector.slice(1), tool: 'click', args: { selector } })));",
        "return { page: page.output, clicked: clicked.map((link) => link.output) };"
      ]
    }
    

    Project playbooks in ./.surf/playbooks/ override user playbooks in ~/.surf/playbooks/; built-ins are the final fallback. show reports the selected source. Provider compatibility commands continue to use their validated command paths until provider playbooks have real login-flow validation.

    Author a playbook from redacted recent activity or an explicit evidence record:

    surf pb suggest --since 1h
    surf pb save example --op read --from-recent 1h
    surf pb record start example --op read --network --watch
    surf pb record mark "loaded results"
    surf pb record stop --draft
    surf pb save --from-record <record-id>
    surf pb trace export --from-record <record-id> --har ./trace.har
    surf pb export example --out ./example-playbook
    surf pb import ./example-playbook
    

    Records, traces, receipts, and recent-use journals live under private Surf state. Input values and authentication headers are redacted by default; --include-input-values is an explicit recording choice.

    Generate a standalone client only from an observed or validated read endpoint:

    surf pb client derive example --op read --from-record <record-id> --request-id <request-id> --out ./client
    surf pb client export example --op read --out ./client
    surf pb client verify ./client
    

    Generated manifests declare provenance and authentication environment inputs. Surf excludes cookies, bearer tokens, and captured credentials and does not export write-capable clients without explicit review.

    Threads and Feeds

    Get a whole thread or feed as clean records in seconds, instead of having an agent scroll and re-read the page. Sites like X and Bluesky keep only a few posts on screen at a time, so scrolling takes dozens of calls and long posts can come back cut short. The page itself gets its posts as data from the site's own API, a page at a time. Surf reads that data directly through your logged-in browser, so sites that need you signed in work and you don't need API keys.

    X threads work out of the box:

    # Every post the author wrote in the thread: long posts in full, quoted posts, images, links
    surf use x thread --url https://x.com/<user>/status/<id> --format md
    

    For any other site, write a recipe once. A recipe is a few dozen lines of JSON, not code, that tell Surf three things:

    which request   the API call that holds the posts    /xrpc/app.bsky.feed.getAuthorFeed
    which fields    what to keep from each post          author, time, text, link
    next page       where the next-page cursor is        cursor
    

    Save it in ~/.surf/playbooks/<site>/ (or .surf/playbooks/<site>/ in a project), and from then on any agent reads that site with one command:

    surf use bsky feed --url https://bsky.app/profile/bsky.app --format md
    
    RunBrowser callsTime
    13-post X thread, scrolling and re-reading the pageabout 30—
    13-post X thread, surf use x thread44.9 s
    83-post Bluesky feed (3 pages), surf use bsky feed with a recipe144.3 s

    To write a recipe, or to grab a single response by hand, the building blocks also work on their own:

    surf network.wait --match "/api/*/feed" --open https://example.com/feed   # the page's own API response
    surf network.replay r_123 --set query.cursor=abc                           # the next page, re-sent from the tab
    surf collect --item article --key 'a@href' --field title=h2                # scroll and read, when there's no API
    

    A step-by-step guide to writing a recipe, the full format, and the limits (scrolling needs a visible tab; replay re-sends only GET and HEAD by default) are in docs/threads-and-feeds.md.

    Global Options

    --session <name>   # Target a durable browser session (or set SURF_SESSION)
    --tab-id <id>      # Target a specific tab
    --window-id <id>   # Target a specific window
    --no-wait          # Return tab_busy/browser_busy instead of queueing
    --json             # Raw JSON including resolved target metadata
    --soft-fail        # Host tool errors: stderr warning, exit 0, no JSON error output
    --no-lock          # Bypass the legacy lock for compound client-side commands
    --no-screenshot    # Skip auto-screenshot after actions
    --settle <ms>      # Max wait for the page to settle before actions report changes (default 2000, max 30000)
    --no-diff          # Skip the page-change report after actions
    --full             # Full resolution screenshots (skip resize)
    

    Host tool-response errors

    For ordinary socket-backed commands, a host response with a top-level error exits 1 and prints Error: ... on stderr. A supplied code is appended as [code] to the first line unless already present there; subsequent recovery lines are preserved. Without a code, no suffix is added.

    --json additionally writes {"error":{"code":"...","message":"...","details":{...}}} to stdout, while retaining stderr and exit 1. The JSON code defaults to "error"; the message uses the host's message, or the first display line if absent. Optional details retain the host's fields except redundant code and message fields.

    --soft-fail takes precedence: the original host display text is printed as a stderr warning, without adding a code, stdout stays empty even with --json, and the command exits 0. This is not a universal JSON error envelope: local validation, transport/parser failures, compound commands and errors embedded in successful result payloads retain their existing behavior. In particular, a connection failure still prints stderr, leaves stdout empty and exits 1 with --json, even with --soft-fail.

    Optional semantic commands

    semantic.act is a bounded, goal-driven website controller. Give it an outcome and it repeatedly observes the current page, asks a decision model to select the next action from Surf's allowed menu, validates and executes that action, then checks whether the overall goal is complete. It stops when the goal is satisfied, a decision is uncertain, or a step, provider-call, or time budget is exhausted.

    agent goal
        |
        v
    Surf observes -> model selects -> Surf validates + acts -> model checks goal
        ^                                                        |
        +---------------- goal incomplete -----------------------+
                                                                 |
                                          complete / uncertain / budget reached
                                                                 |
                                                                 v
                                                            return result
    

    The other semantic commands expose individual parts of that loop:

    semantic.find     select one control matching a goal
    semantic.filter   rank the page regions relevant to a goal
    semantic.verify   check whether one outcome is visible
    semantic.act      run the bounded observe/choose/act/verify loop
    semantic.extract  read typed values (prices, dates, ids) off the page
    

    This is most useful when the agent does not yet know a site's structure or happy path: the model handles next-action selection and goal verification while Surf builds the allowed action menu and enforces permissions, confidence thresholds, and element freshness. The agent owns the goal and final confirmation. Once the path is known and stable, deterministic Surf commands are usually faster and more reliable for repeated execution.

    Semantic commands are an explicit remote-AI boundary: only surf semantic.* (and semantic surf do steps) send a bounded, value-free current-page observation, and it goes to the provider of the selected model (semantic.extract always sends it to Cloudflare Workers AI). Existing Surf commands do not read a provider credential, load the SDK, or make provider calls.

    ModelProviderEndpointCredentials
    jev-1.13.0 (default)TypeSafeapi.typesafe.aiTYPESAFE_API_KEY or surf semantic auth set
    clefCloudflare Workers AIapi.cloudflare.comCLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN, or surf semantic auth set --provider cloudflare
    clef-flashCloudflare Workers AIapi.cloudflare.comsame as clef
    clairic (only semantic.extract)Cloudflare Workers AIapi.cloudflare.comsame as clef

    Pick a model with --model <id> on semantic.find, semantic.filter, semantic.verify, and semantic.act, or with SURF_SEMANTIC_MODEL (the only way to choose it for surf do). --model wins over the variable. Each model has its own measured confidence thresholds; see Semantic model evaluation for how they were chosen and how the models compared. Every result reports a run summary: provider, model, providerCalls, and providerLatencyMs (summed provider round-trip time), including runs stopped by a budget or a failed decision. When a command fails, Surf prints the summary on stderr after the error.

    surf semantic.find "the control for notification preferences"
    surf semantic.verify "Notification preferences were saved" --json
    surf semantic.filter "notification preferences" --top 6
    surf semantic.act "Open notification settings" --max-steps 5
    surf semantic.act "Fill the email field" --input email="$EMAIL" --allow-write
    surf semantic.act 'Add the selected item to the cart' --allow-write --threshold write=0.85
    surf semantic.find "the export button" --model clef-flash
    SURF_SEMANTIC_MODEL=clef surf semantic.verify "Settings were saved"
    surf semantic.extract --field total:number:"Order total including tax" --field delivered:date
    surf semantic.extract --fields-json '{"total":{"type":"number","unit":"USD","decimals":2},"items":{"type":"list","items":"text"}}'
    surf semantic auth set       # TypeSafe key: hidden prompt, or exactly one stdin line
    surf semantic auth set --provider cloudflare   # account id, then hidden token
    surf semantic auth status    # each provider's source and redacted fingerprint only
    surf semantic a