装完 CLI 还需在 chrome://extensions 开启开发者模式并手动加载 surf install 打印的扩展目录;需保持开发者模式开启,否则自动重载的未打包扩展会被 Chrome 禁用;通过 Nix、Homebrew 等包管理器安装时需先设置 SURF_NODE_PATH、SURF_HOST_PATH、SURF_EXTENSION_PATH 环境变量。
surf-cli
nicobailonMIT活跃维护让 AI Agent 通过命令行直接控制 Chrome 的 CLI 工具,零配置、不绑定特定 Agent,开箱即用。
The CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.
npm install -g surf-cli
surf installREADME 未说明显存或 GPU 要求;需在 macOS、Linux 或 Windows 上安装 Chrome 等浏览器并加载扩展,支持 chrome、chromium、brave、edge、arc、helium。
让 AI 代理通过纯 CLI 命令控制 Chrome 的零配置工具,不绑定任何模型
让 AI Agent 通过命令行直接控制 Chrome 的 CLI 工具,零配置、不绑定特定 Agent,开箱即用。
核心能力
- 通过 Unix socket 执行纯 CLI 命令,任何能跑命令的代理都能用
- 用无障碍树生成稳定元素引用 e1、e2,可直接点击和输入
- 支持按 ARIA 角色、文本、表单标签做语义定位并直接执行动作
- 自动记录网络请求,可过滤、搜索并重放 API 调用
- 复用浏览器登录态查询 ChatGPT、Gemini 等,无需 API key
容易踩的坑
- 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
locateactions used to printOKeven 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 readalso 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 printingOK. Clicks wait for the element to be ready and use a real mouse click, andlocateno longer clicks "Save draft" when you asked for "Save". - One command to update.
surf updateupdates the CLI, the browser connection and the agent skill, then reloads the extension. When the parts get out of step, every command warns you. readsees 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 checkandsurf uncheckset a checkbox to the state you want.surf page.text --refprints a field's whole value.surf type --escapestypes real newlines and tabs.
Need to know
Who What to do Existing Surf users Surf's extension ID changes once. Run surf install, then reload Surf inchrome://extensions. Settings saved in the old copy, such as Debug Mode, don't carry over.Scripts that relied on OKActions that used to print OKwithout working now exit with an error. These include clicking a disabled or covered element, an ambiguouslocate --action, text too long for its field, and a dropdown value with no matching option.Highlights
- When a click, typed text, dropdown choice or
locateaction doesn't work, Surf now fails and says why (e5 is disabled,e2 is covered by dialog "Cookie consent") instead of printingOK. Clicks also wait for the element to be ready and use a real mouse click. surf updateupdates the CLI, the browser connection and the agent skill, then reloads the extension, all in one command.surf installno longer asks for an extension ID.surf readsees 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 checkandsurf uncheckset a checkbox to the state you want, andsurf page.text --refprints a field's whole value. - If you already use Surf, its extension ID changes once: rerun
surf install, then reload Surf inchrome://extensions.
Added
surf checkandsurf uncheckset 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>andsurf 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.uncheckon 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 }insurf.jsonand 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
- Clicks and typing fail honestly. Surf says why an action didn't work (
- 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_IDand 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 readingsurf --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
grepor an agent readingsurf --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_IDto 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 priceor an agent readingsurf --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.
- Your own AI Gateway for Surf's Cloudflare calls: set
- 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 geminialso works again, after every request had been failing withHeader overflow. Sessions opened in their own window now stay there.Highlights
- Your Google cookies only go to Google.
surf geminirejects fake, expired or mismatched certificates, like any other HTTPS client. surf geminiworks again. Gemini's large responses no longer fail withHeader 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 geminiran, for example on a hostile public Wi-Fi network. Ordinary networks were not affected. If you ransurf geminion 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 gemininow 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 geminiworks again; every request had been failing withHeader 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, likedrag, clicks by coordinates andcollect, then did nothing. The tab now stays in the session's window. See #403. surf geminiworks again - Everysurf geminirequest failed withHeader overflow, because gemini.google.com sends more response headers than Node accepts by default. The client now accepts them.
Security
surf geminichecks 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 ransurf geminion a network you don't trust, sign out of other sessions in your Google Account's security settings to invalidate any copied cookies.
- Your Google cookies only go to Google.
- 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, includingdrag, right-clicks andtype --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> --helpcovers every command and lists only flags that do something, with examples for the tricky ones.surf --find threadfindssurf use x thread, andsurf bookmarkssuggestsbookmark.listinstead of failing in the browser. - Commands that do what they say.
surf drag --from x,y --to x,ydrags,surf click e5 --button rightright-clicks,surf type "…" --submitpresses Enter, and the browser shows the extension's real version instead of 2.6.0.
If you installed the skill by copying
SKILL.mdon its own, copy or symlink the wholeskills/surffolder instead, so itsreferences/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> --helpcovers every command and only lists flags that do something, with examples for the tricky ones such asform.fill.surf --findsearches all of them, sosurf --find threadfindssurf 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,ydrags,surf click e5 --button rightright-clicks instead of left-clicking,surf type "…" --submitpresses 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 toskills/surf/references/, which the agent opens only when a task needs them. If you install the skill by hand, copy or symlink the wholeskills/surffolder so those files come along. Commands and flags the old skill showed that never worked, such askey.repeat,bookmarksandcookie 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
- 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
- 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, ornullwhen it isn't on the page.surf doworkflows 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
--visionit 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 typeno longer drops or scrambles punctuation, andsurf searchandsurf locate.rolepoint at the element you meant.
Need to know
If you set
SURF_JEV_MODEL, rename it toSURF_SEMANTIC_MODEL. Surf no longer reads the old name, and the value must bejev-1.13.0,cleforclef-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
--visionit 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, ornullwhen it isn't on the page.surf doworkflows 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 typeno longer drops or scrambles punctuation, andsurf searchandsurf locate.rolepoint at the element you meant.
Added
- Choose the semantic model -
semantic.find,semantic.filter,semantic.verifyandsemantic.acttake--model jev-1.13.0|clef|clef-flash.SURF_SEMANTIC_MODELsets the default, and it is the only way to pick the model for semanticsurf dosteps.
- 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
The CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.
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
| Feature | Surf | Manus | Claude Extension | DevTools MCP | dev-browser |
|---|---|---|---|---|---|
| Agent-agnostic | Yes | No (Manus only) | No (Claude only) | Partial | No (Claude skill) |
| Zero config | Yes | No (subscription) | No (subscription) | No (MCP setup) | No (relay server) |
| Self-hosted (local or Tailnet) | Yes | No (cloud) | Partial | Yes | Partial |
| CLI interface | Yes | No | No | No | No |
| Free | Yes | No | No | Yes | Yes |
| AI via browser cookies | Yes | No | No | No | No |
| Whole threads and feeds as records, via the site's own API | Yes | No | No | Partial (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
| Command | After frame.switch |
|---|---|
read, page.text, page.state, click, type, select, locate.* | Use the selected frame, including reachable out-of-process iframes. |
js | Fails 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 commands | Unaffected. |
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 fromSURF_SEMANTIC_MODEL, defaultjev-1.13.0;extractsteps always useclairicwith Cloudflare credentials)--allow-write- Additionally authorize declaredfill,ensureChecked, andclicksteps--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
| Run | Browser calls | Time |
|---|---|---|
| 13-post X thread, scrolling and re-reading the page | about 30 | — |
13-post X thread, surf use x thread | 4 | 4.9 s |
83-post Bluesky feed (3 pages), surf use bsky feed with a recipe | 14 | 4.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.
| Model | Provider | Endpoint | Credentials |
|---|---|---|---|
jev-1.13.0 (default) | TypeSafe | api.typesafe.ai | TYPESAFE_API_KEY or surf semantic auth set |
clef | Cloudflare Workers AI | api.cloudflare.com | CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN, or surf semantic auth set --provider cloudflare |
clef-flash | Cloudflare Workers AI | api.cloudflare.com | same as clef |
clairic (only semantic.extract) | Cloudflare Workers AI | api.cloudflare.com | same 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