<p>
<img src="surf-banner.png" alt="surf" width="1100">
</p>
# Surf
**The CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.**
[](https://www.npmjs.com/package/surf-cli)
[](LICENSE)
[]()
> See [CHANGELOG](CHANGELOG.md) for current release notes.
```bash
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
```bash
# 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
```bash
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):
```json
{ "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:
```bash
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`:
```bash
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](#environment-variables) for details.
### Uninstall
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```json
{
"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**
```bash
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:
```bash
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
```bash
git clone https://github.com/nicobailon/surf-cli.git
cd surf-cli
npm install
npm run build
# Then load dist/ as unpacked extension
```
## Usage
```bash
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
```bash
surf go "https://example.com"
surf back
surf forward
surf tab.reload --hard
```
### Reading Pages
```bash
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:
```bash
# 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 "test@example.com"
```
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:
```bash
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.
```bash
surf frame.diagnose # Human-readable report with warnings
surf frame.diagnose --json # Full inventories
```
### Interaction
```bash
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@example.com" --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:
```bash
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):
```bash
surf check e6 # Checked afterwards, or the command fails
surf uncheck e6 # Unchecked afterwards
```
Upload files:
```bash
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:
```bash
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):
```bash
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
```bash
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.
```bash
# 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.
```bash
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:
```text
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:
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```bash
# 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.
```bash
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
```bash
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.
```bash
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.
```bash
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.
```bash
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.
```bash
# 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:
```bash
# 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 "user@example.com" --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:**
```json
{
"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:
```json
{
"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:
```json
{
"steps": [
{ "tool": "js", "args": { "code": "return ['a', 'b', 'c']" }, "as": "items" },
{
"each": "%{items}",
"as": "item",
"steps": [
{ "tool": "js", "args": { "code": "return 'Processing: %{item}'" } }
]
}
]
}
```
```json
{
"steps": [
{
"repeat": 5,
"steps": [
{ "tool": "scroll", "args": { "direction": "down" } },
{ "tool": "wait", "args": { "duration": 500 } }
]
}
]
}
```
**Loop with exit condition** - Stop early when condition is met:
```json
{
"repeat": 20,
"until": { "tool": "js", "args": { "code": "return !document.querySelector('.next-page')" } },
"steps": [
{ "tool": "click", "args": { "selector": ".next-page" } },
{ "tool": "wait.load" }
]
}
```
#### Workflow Management
```bash
# 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:
```bash
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
```
```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:
```json
{ "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`:
```json
{ "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.
```bash
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`.
```json
{
"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:
```bash
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:
```bash
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:
```bash
# 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:
```bash
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:
```bash
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](docs/threads-and-feeds.md).
## Global Options
```bash
--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.
```text
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:
```text
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](docs/semantic-models.md) 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.
```bash
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 auth clear # removes stored credentials (--provider narrows it)
# Ephemeral/CI override (highest precedence; does not modify the stored key)
TYPESAFE_API_KEY="$CI_TYPESAFE_KEY" surf semantic.find "the checkout link"
# Non-interactive persisted setup (exactly one bounded line on stdin)
printf '%s\n' "$TYPESAFE_KEY" | surf semantic auth set
# Cloudflare: two stdin lines, account id then token
printf '%s\n%s\n' "$CLOUDFLARE_ACCOUNT_ID" "$CLOUDFLARE_API_TOKEN" | surf semantic auth set --provider cloudflare
```
#### Reading values (`semantic.extract`)
`semantic.extract` reads specific values off the page, such as a price, a date
or an order id, so the agent doesn't have to read the whole page to find them.
Name each value with `--field name:type[:instructions]`. The types are `text`,
`number`, `integer`, `date`, `time`, `datetime`, `duration` (or a unit such as
`duration.minutes`), `list`, `list.number` and `list.date`. `--fields-json`
takes the same fields as JSON when you need units, decimals or a per-field null
threshold. Each value comes back with a `confidence` and a status: `extracted`;
`null` when the value isn't on the page; or `uncertain` when the confidence is
below the threshold, in which case the value is withheld and reported as
`guess`. A text value also names the page text chunk or control ref it came
from, when Surf can match it exactly.
It runs on Cloudflare's Clairic extraction model (a preview), uses the same
Cloudflare credentials as `clef`, and sends the same value-free page text to
Cloudflare. `SURF_SEMANTIC_MODEL` doesn't apply to it. On 300 field results (30
fields, 10 runs each) used to set its threshold, it reported no wrong value as
extracted; see
[Extraction (Clairic)](docs/semantic-models.md#extraction-clairic).
#### Icon-only controls (`--vision`)
A button with only an icon and no label, title, or alt text has no name, so the
model sees several identical `button` candidates. `--vision` on `semantic.find`
and `semantic.act` lets it tell them apart:
```bash
surf semantic.find "the download icon" --model clef --vision
surf semantic.act "Open settings" --model clef --vision --allow-write
```
- **What is sent.** One extra PNG image of the controls in the main frame that have no name (at most 16), with each tile tagged with its ref. It goes to the selected model's provider along with the usual page observation.
- **Where the tiles come from.** Each tile is the control's own icon drawn on a blank white canvas at the control's size. Surf uses the first of these it finds in the control:
1. an inline SVG, including a `<use>` of a symbol in the same page;
2. an image (`<img>`, `<picture>` or `<input type=image>`);
3. an icon-font glyph, from the element's text (a ligature such as Material Icons' `home`) or its `::before`/`::after` content (Font Awesome style);
4. a CSS `mask-image`, filled with the element's background color or text color;
5. a CSS `background-image` (its first `url()` layer, with its size and position).
No screenshot of the page is taken or sent, so field content, pickers, validation messages, popovers, frames and anything else on the page can't appear in the image. The icon is drawn with the control's current styles; hover and focus states aren't reproduced.
- **Images.** Same-origin, `data:` and CORS-readable images are drawn as the page has them. For a cross-origin image the page can't share, the extension fetches it again from its URL, over HTTPS only, without cookies or redirects, preferring the browser cache, and only up to 512 KiB and 2 seconds.
- **Skipped.** A control gets no tile, and is counted in `skipped`, when:
- its icon is drawn on a `<canvas>` or with CSS gradients or borders;
- it uses `content: url()`, or an SVG `<use>` that points to an external file;
- its text isn't in a web font the page loaded (plain text is not an icon), or the icon font failed to load;
- it has a cross-origin mask without CORS (the page itself doesn't draw that one) or several mask layers;
- its image can't be loaded or fetched;
- its background or mask position can't be read (more than two values, or lengths such as `calc()`), or its icon is inside the control's own shadow DOM;
- it is past the first 16 such controls, or the read is of a frame other than the main one.
- **Caveats.** Canvas can't set an icon font's variable axes, so Material Symbols with `FILL` or `wght` set show as the default outline. Children of a `<use>` symbol that are styled only by page style sheet selectors lose those styles (inherited colors and `currentColor` still work). An image fetched again may differ from what the page showed if its URL now returns different bytes. Background and mask images are placed approximately: `background-repeat` and `object-fit` aren't reproduced.
- **When.** Only when the page has such controls. On a fully labeled page nothing extra is sent.
- **Models.** Only image-capable models: `clef` and `clef-flash`. With `jev-1.13.0`, the command fails before anything is sent: `--vision needs an image-capable model such as --model clef`. `semantic.verify`, `semantic.filter`, and `surf do` don't take it.
- **Results.** `vision: { tiles, skipped }` counts the controls shown and the ones left out. For `semantic.act` the counts come from the read behind the last action decision. The `find` candidate, an `act` trace step, or an `act` decision whose control was shown as a tile carries `"tile": true`. It records what the model was shown, not why it chose. Before relying on a tiled write target, take a screenshot.
- **Safety.** Confidence thresholds, `--allow-write`, and `--allow-ref` work exactly as without `--vision`.
- **Failures.** If the tiles can't be built, the command fails with `semantic vision failed: <cause>`. It never silently falls back to text only.
How much `--vision` helps, and what it costs, is measured in
[Semantic model evaluation](docs/semantic-models.md#icon-only-controls---vision).
On that page it also made the model less sure about a labeled button, so use
`--vision` when the target is likely an icon-only control, not by default.
The provider-neutral shared schema is `{"version":1,"apiKey":"..."}`. Persisted
setup lives at `${XDG_CONFIG_HOME:-~/.config}/typesafe/credentials.json` on
Unix/macOS and `%APPDATA%\TypeSafe\credentials.json` on Windows, independent of
`SURF_STATE_DIR`, project, and cwd. On POSIX, directories use mode `0700` and the
file mode `0600`; writes are atomic and symlinked paths are rejected. Windows
uses the current user's profile and ACL semantics. A nonblank `TYPESAFE_API_KEY`
always wins over the shared file; `auth status` prints only `environment`,
`shared-store`, or `not-configured` plus a short fingerprint. `auth clear`
removes the shared file for every client that uses it while an environment
override remains effective. Keys are never accepted on argv or from
`surf.json`, project config, or auto-loaded `.env` files, and are never sent to
the host/extension or included in logs, errors, or JSON output. Install,
configuration, doctor, startup, and non-semantic commands never prompt for a key
or load the TypeSafe SDK.
Cloudflare credentials live in their own record,
`${XDG_CONFIG_HOME:-~/.config}/surf/cloudflare-credentials.json` (or
`%APPDATA%\surf\cloudflare-credentials.json`), with the same file modes, atomic
writes, and symlink rejection. `CLOUDFLARE_ACCOUNT_ID` and
`CLOUDFLARE_API_TOKEN` win when both are set; setting only one is an error.
Status never prints the account id or token, only `environment`, `stored`, or
`not-configured` plus a fingerprint.
Set `CLOUDFLARE_GATEWAY_ID` to send every Clef and Clairic call through one of
your [AI Gateways](https://developers.cloudflare.com/ai-gateway/) for its logs,
analytics, rate limits and caching. Surf calls the same Workers AI endpoint
with the same token and adds a `cf-aig-gateway-id` header, so no second token is
needed. The gateway logs each request, including the page observation (page text
and control names, never typed field values), unless you turn logging off in
the gateway.
`semantic.act` is bounded to observed same-origin HTTP(S) links, fixed scrolling
and waits, and observed refs. Every DOM click and fill is mutation-capable and is
excluded unless `--allow-write` is present. That flag intentionally permits
high-impact submit, purchase, delete, send, and publish controls; repeat
`--allow-ref <ref>` to narrow authorization to exact current refs. Fill values
come only from named `--input name=value` slots and are never sent to the model provider or
included in traces. Broad and ambiguous writes require probability `0.95`
(`0.85` with `clef`). With
`jev-1.13.0` the threshold is `0.65` (`clef`: `0.79`) only when exactly one `--allow-ref` names exactly one
applicable click, or one fill with one input slot; the applied threshold appears
in decision/trace output and never grants authority. Repeatable
`--threshold name=value` overrides applicable confidence thresholds for one run
only; defaults remain safer, and overrides never replace `--allow-write` or
`--allow-ref` authority. Actions are
freshness-guarded and uncertain writes are not replayed. Each provider choice is
capped at 70 actions: six fixed scroll/wait
actions plus at least one action for each of the 64 observed refs explicitly
authorized with `--allow-ref`; additional variants are omitted deterministically.
An oversized mandatory authorized set fails before provider selection. Page text
remains adversarial data; model output never grants authority.
The real-model evaluation harness is opt-in and excluded from CI:
`SURF_REAL_SEMANTIC=1 npm run eval:semantic -- --models jev-1.13.0,clef,clef-flash`.
`SURF_REAL_SEMANTIC=1 npm run eval:vision` measures `--vision` in real Chrome
(run `npm run build` first).
## Environment Variables
```bash
SURF_NETWORK_PATH # Native-host network state root (default: ~/.surf/state/network)
SURF_STATE_DIR # Private Surf state root; does not affect semantic provider credentials
SURF_SESSION # Default named browser session for tab-scoped commands
SURF_SOCKET # Socket path or named pipe (default: /tmp/surf.sock, Windows: //./pipe/surf)
SURF_REMOTE # Remote Surf endpoint as host:port (overrides SURF_SOCKET)
SURF_REMOTE_CREDENTIAL # Client Ed25519 credential for the selected remote endpoint
SURF_REMOTE_TLS # Exactly 1 enables TLS for a selected remote endpoint
SURF_REMOTE_TLS_CA # Custom CA bundle that replaces system roots
SURF_REMOTE_TLS_SERVER_NAME # DNS SNI and certificate identity override
SURF_REMOTE_STATE_DIR # Host identity/authorization directory (default: ~/.surf/remote)
SURF_LISTEN # Native-host Tailnet bind address as <tailscale-ip>:<port>
SURF_SOCKET_MODE # Advanced POSIX local socket mode: 600 (default) or 660
SURF_SOCKET_GROUP # Group name or numeric gid required with mode 660
SURF_NODE_PATH # Path to node binary (for native host wrapper)
SURF_HOST_PATH # Path to native/host.cjs (for native host wrapper)
SURF_EXTENSION_PATH # Path to extension dist/ directory
TYPESAFE_API_KEY # Optional Jev credential; overrides the shared store
CLOUDFLARE_ACCOUNT_ID # Optional Clef credential (with CLOUDFLARE_API_TOKEN); overrides the stored record
CLOUDFLARE_API_TOKEN # Optional Clef credential (with CLOUDFLARE_ACCOUNT_ID)
CLOUDFLARE_GATEWAY_ID # Optional AI Gateway id for Clef and Clairic calls
SURF_SEMANTIC_MODEL # Semantic model: jev-1.13.0 (default), clef, or clef-flash
XDG_CONFIG_HOME # Unix/macOS base for semantic provider credentials (default: ~/.config)
```
**Use cases:**
- `SURF_SESSION`: Per-shell default session. Give each independent agent a unique value and run `surf session.ensure "$SURF_SESSION" about:blank` before its first browser command.
- `SURF_STATE_DIR`: Private mode-0700 state root for durable browser-session bindings and other Surf state.
- `SURF_SOCKET`: Advanced socket override. Set it for both the native host and CLI when separate browser/profile instances need hard isolation.
- `SURF_REMOTE`: Remote client endpoint. `--remote <host>:<port>` overrides it; both override `SURF_SOCKET`.
- `SURF_REMOTE_CREDENTIAL`: Credential used for mutual remote authentication. `--remote-credential <path>` overrides it.
- `SURF_REMOTE_TLS`: Set exactly `1` for TLS through a terminating reverse proxy; `--remote-tls` also enables it and cannot negate an env-enabled setting.
- `SURF_REMOTE_TLS_CA`: CA bundle for remote TLS, replacing system roots. `--remote-tls-ca <path>` overrides it.
- `SURF_REMOTE_TLS_SERVER_NAME`: DNS SNI and certificate identity override. `--remote-tls-server-name <name>` overrides it.
- `SURF_REMOTE_STATE_DIR`: Advanced host-side override for the mode-0700 identity and client registry directory.
- `SURF_LISTEN`: Native-host listener address on the browser machine. Use `surf install ... --listen <tailscale-ip>:<port>` to persist it in that host's wrapper.
- `SURF_SOCKET_MODE` / `SURF_SOCKET_GROUP`: Advanced POSIX native-host settings. Use `surf install ... --socket-mode 660 --socket-group <group>` to persist group access; mode `660` grants full Surf authority to every member of that group.
- `SURF_NODE_PATH` / `SURF_HOST_PATH`: Package manager installs (e.g., Nix) that store binaries in non-standard locations
- `SURF_EXTENSION_PATH`: Package managers that create stable symlinks instead of changing paths on reinstall
- `TYPESAFE_API_KEY`: Used only by explicit `semantic.*` networked commands with a Jev model. Otherwise use `surf semantic auth set` for the provider-neutral shared store.
- `CLOUDFLARE_ACCOUNT_ID` / `CLOUDFLARE_API_TOKEN`: Used only by semantic commands with a Clef model. Otherwise use `surf semantic auth set --provider cloudflare`.
- `CLOUDFLARE_GATEWAY_ID`: Routes Clef and Clairic calls through that Cloudflare AI Gateway. Unset means direct Workers AI calls.
- `SURF_SEMANTIC_MODEL`: Default model for semantic commands and the model for semantic `surf do` steps; `--model` overrides it on commands. Replaces `SURF_JEV_MODEL`, which is no longer read.
**Example (Nix):**
```bash
export SURF_NODE_PATH=~/.local/share/surf-cli/node
export SURF_HOST_PATH=~/.local/share/surf-cli/native/host.cjs
export SURF_EXTENSION_PATH=~/.local/share/surf-cli/extension
```
## Troubleshooting native host connections
If a command fails with `Socket connect failed`, start with:
```bash
surf doctor
surf doctor --browser all
surf doctor --json
```
`doctor` does not require a working browser connection. It checks the socket path, native messaging manifest, manifest `allowed_origins`, and wrapper path, then prints targeted next steps.
Read the `Attempted socket:` line first. The CLI and native host must agree on the same socket path. By default this is `/tmp/surf.sock` on macOS/Linux/WSL2 and `//./pipe/surf` on Windows.
Common fixes:
- Restart the browser after `surf install`.
- Confirm the Surf extension is enabled and its ID is `oaclimdkkglbnpekeignmjenkodkaaka` (or the ID you passed to `surf install`).
- On WSL2 with Windows Chrome, run `surf install` from WSL2 and restart Windows Chrome. Use `--target linux` only for a Linux browser running inside WSLg.
- If `SURF_SOCKET` is set, set the same value for both the browser-launched native host and the shell running `surf`.
macOS checklist:
- Confirm Chrome has a native messaging manifest at `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/surf.browser.host.json`.
- Confirm the manifest `allowed_origins` entry uses the same extension ID shown on `chrome://extensions` for the Surf extension.
- Rerun `surf install` if the extension ID changed, for example after updating from a Surf version without a fixed ID.
- Fully restart Chrome, then reload the Surf extension on `chrome://extensions`.
- Open Surf's service worker console from `chrome://extensions`. In Surf's **Details > Extension options**, enable **Debug Mode**, reproduce the failure, then disable **Debug Mode** when finished.
- If `SURF_SOCKET` is set in your shell, make sure Chrome launches the native host with the same value; otherwise both sides should use `/tmp/surf.sock`.
- Run a simple CLI command such as `surf tab.list`; if it fails, compare its `Attempted socket:` line with the socket expected by the native host.
## Socket API
For programmatic integration, send JSON to `/tmp/surf.sock` by default, or to `SURF_SOCKET` when set:
```bash
echo '{"type":"tool_request","method":"execute_tool","params":{"tool":"tab.list","args":{}},"id":"1"}' | nc -U /tmp/surf.sock
```
### Protocol Reference
**Request:**
```json
{
"type": "tool_request",
"method": "execute_tool",
"params": {
"tool": "click",
"args": { "ref": "e5" }
},
"id": "unique-request-id",
"tabId": 123,
"windowId": 456
}
```
**Success Response:**
```json
{
"type": "tool_response",
"id": "unique-request-id",
"result": {
"content": [{ "type": "text", "text": "Result message" }]
}
}
```
**Error Response:**
```json
{
"type": "tool_response",
"id": "unique-request-id",
"error": {
"content": [{ "type": "text", "text": "Error message" }]
}
}
```
## Command Groups
| Group | Commands |
|-------|----------|
| `workflow` | `do`, `workflow.list`, `workflow.info`, `workflow.validate` |
| `window.*` | `new`, `list`, `focus`, `close`, `resize` |
| `tab.*` | `list`, `new`, `switch`, `close`, `name`, `unname`, `named`, `group`, `ungroup`, `groups`, `reload` |
| `scroll.*` | `top`, `bottom`, `to`, `info` |
| `page.*` | `read`, `text`, `state`, `readiness` |
| `locate.*` | `role`, `text`, `label` |
| `element.*` | `styles` |
| `frame.*` | `list`, `diagnose`, `switch`, `main`, `js` |
| `wait.*` | `element`, `network`, `url`, `dom`, `load`, `ready` |
| `cookie` / `cookie.*` | `list`, `get`, `set`, `clear`, `delete` |
| `bookmark.*` | `add`, `remove`, `list` |
| `history.*` | `list`, `search` |
| `dialog.*` | `accept`, `dismiss`, `info` |
| `emulate.*` | `network`, `cpu`, `geo`, `device`, `viewport`, `touch` |
| `perf.*` | `start`, `stop`, `metrics` |
| `network.*` | `get`, `body`, `curl`, `origins`, `clear`, `stats`, `export`, `path` |
## Aliases
| Alias | Command |
|-------|---------|
| `snap` | `screenshot` |
| `read` | `page.read` |
| `find` | `search` |
| `go` | `navigate` |
## How It Works
```
CLI (surf) → Unix Socket → Native Host → Chrome Extension → CDP/Scripting API
```
Surf uses Chrome DevTools Protocol for most operations, with automatic fallback to `chrome.scripting` API when CDP is unavailable (restricted pages, certain contexts). Screenshots fall back to `captureVisibleTab` when CDP capture fails.
## Limitations
- Cannot automate `chrome://` pages or the Chrome Web Store (Chrome restriction)
- First CDP operation on a new tab takes ~100-500ms (debugger attachment)
- Some operations on restricted pages return warnings instead of results
## Linux Support (Experimental)
Surf should work on Linux with Chromium. Not yet tested in production.
```bash
# Install dependencies
sudo apt install chromium-browser nodejs npm imagemagick
# For headless server: add Xvfb + VNC
sudo apt install xvfb tigervnc-standalone-server
# Install Surf and native host
npm install -g surf-cli
surf install --browser chromium
```
**Notes:**
- Use Chromium (no official Chrome for Linux ARM64)
- Screenshot resize uses ImageMagick instead of macOS `sips`
- Headless servers need Xvfb + VNC for initial login setup
## AI Agent Integration
Surf includes a skill file for AI coding agents like [Pi](https://github.com/badlogic/pi-mono):
```bash
# Symlink for auto-updates
ln -s "$(pwd)/skills/surf" ~/.pi/agent/skills/surf
# Or copy
cp -r skills/surf ~/.pi/agent/skills/
```
See [`skills/README.md`](skills/README.md) for details.
### Pi extension
Surf also includes an optional Pi extension. Install or load Surf as a Pi package, or load it from a checkout:
```bash
pi install npm:surf-cli
pi -e /path/to/surf-cli/pi-extension/surf.ts
```
It registers `surf_read`, `surf_screenshot`, `surf_click`, `surf_type`, `surf_tool`, and the `surf_oracle_*` tools. Browser calls use Surf's native-host socket, not shell commands. If `pi-subagents/background-work` is installed, the extension also reports active oracle jobs started by that Pi session. Pi still loads the browser tools when pi-subagents is not installed.
The extension also registers a `surf-oracle` external-job provider when a Pi runtime exposes that provider bridge. The provider implements pi-subagents' external-job contract: `start`, `status`, `result`, and `reattach` operations that return `providerJobId`, a contract state (`queued`, `running`, `completed`, `failed`), the durable conversation URL, the captured result text as `output`, and failure code and message when present. It reads `options.model`, `options.effort`, `options.file`, and `options.github` for starts and follow-ups, so a Pi profile can request `model: gpt-6-astra` plus `effort: pro` and reach ChatGPT GPT-6 Astra with Pro effort through Surf, while `github: true` requires Chat mode and the connected GitHub tool. Capacity stays fail-closed: Surf returns the blocking job id instead of silently queueing a second ChatGPT job.
When Surf is installed as a Pi package, it also exposes an optional `gpt-pro` package agent for `pi-subagents`. That profile uses `runner.type: external-job`, provider `surf-oracle`, `options.model: gpt-6-astra`, and `options.effort: pro`. Surf remains useful without Pi or `pi-subagents`; the package agent only wires Surf's browser-backed model alias into Pi's agent picker.
Shell-based agents should select a unique session with `SURF_SESSION` and call `surf session.ensure` before their first browser command. The optional Pi extension still uses its existing socket-tool interface; callers that coordinate several Pi workers should pass explicit tab targets until session selection is exposed by that integration.
## Development
```bash
npm run dev # Watch mode
npm run build # Production build
```
After changes:
- **Extension** (`src/`): Reload at `chrome://extensions`
- **Host** (`native/`): Restart `node native/host.cjs`
## License
MIT