nicobailon/surf-cli(opens in a new tab)
Language: TypeScript
The CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.
- stars
- 680
- watchers
- 1
- forks
- 62
- open issues
- 2
- Last updated
- 10/26/2026
Language: TypeScript
The CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.
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
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.
| 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 |
# 1. Install globally npm install -g surf-cli # 2. Load extension in Chrome # - Open chrome://extensions # - Enable "Developer mode" # - Click "Load unpacked" # - Paste the path from: surf extension-path # 3. Install native host (copy extension ID from chrome://extensions) surf install <extension-id> # 4. Restart Chrome and test surf tab.list
surf install <extension-id> # Chrome (default) surf install <extension-id> --browser brave # Brave surf install <extension-id> --browser helium # Helium surf install <extension-id> --browser all # All supported browsers surf install <extension-id> --target linux # WSLg/Linux browser from WSL2
Supported: chrome, chromium, brave, edge, arc, helium
WSL2 with Windows Chrome
When you run surf install <extension-id> 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 <extension-id> --target linux
Restart Windows Chrome after installing. If the extension reports Access to the specified native messaging host is forbidden, rerun surf install <extension-id> from the same WSL distro and confirm the extension ID was copied from chrome://extensions.
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.
surf uninstall # Chrome only surf uninstall --all # All browsers + wrapper files surf uninstall --target linux # Remove WSLg/Linux-browser config from WSL2
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 <extension-id> --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 <extension-id> --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.jsonUse 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.
git clone https://github.com/nicobailon/surf-cli.git cd surf-cli npm install npm run build # Then load dist/ as unpacked extension
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
surf go "https://example.com"
surf back
surf forward
surf tab.reload --hardsurf 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 # Raw text content 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.
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 --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"
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 raw iframe text surf page.state # Inspect iframe UI state surf click e5 # Click in iframe surf type "4242" --into "#card-number" surf locate.role button --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
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
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
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.
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 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.
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
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.
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
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).
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.gifrecord 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.
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.
Sample matching elements over time and return a bounded JSON timeline for agent inspection:
surf animate-audit --selector ".thing" --duration 2000 --fps 10The 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.
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 --jsonperf-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.
Capture performance metrics and traces:
surf perf.metrics # Current performance metrics surf perf.start # Start tracing surf perf.stop # Stop and get trace data
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
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.
surf wait 2 # Wait 2 seconds surf wait.element ".loaded" #
| ||
|
| | | |
| | | |
| | | |
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |