Multi-provider web skill for AI coding agents.
Fronts Tavily and Parallel AI behind a single CLI + Node library, with automatic key rotation, provider fallback, and last-known-good persistence.
Two skills. Three providers. One install. npm i -g surf-skill now bundles
both surf-search-skill (multi-provider web search) and surf-plan-skill
(research-driven execution planning), plus a friendly surf setup wrapper with
live key validation.
┌──▶ Tavily (search, extract, crawl, map, research)
search ─┐ │
extract ─┤ │
crawl ──┼──▶ surf-search-skill ──▶ Parallel (search, extract, research async)
map ──┤ │
research ─┘ │
└──▶ Brave (search only — own index)
plan / design ──▶ surf-plan-skill ──┐
architect / spec ──────────────────►│ (calls surf-search-skill for web research)
└──▶ Markdown plan file with [^N] citations
| Status | v4.0.1 (npm) |
| Install | npm i -g surf-skill (Linux · macOS · Windows) |
| Skills shipped | surf-search-skill (search) + surf-plan-skill (planning) |
| Bins shipped | surf (interactive setup + validation), surf-search-skill, surf-plan-skill |
| Runtime | Node ≥ 18. Zero npm deps. |
| Storage | ~/.config/surf/keys.json (chmod 600). Never read from env at runtime by the CLI. |
| Supported agents | Claude Code · GitHub Copilot CLI · Pi Coding Agent · OpenCode · Codex CLI |
| Spec | Anthropic Agent Skills |
npm i -g surf-skill # installs BOTH skills + 3 bins (cross-OS)
surf # interactive: add keys with LIVE validation
# ✓ valid (tavily, HTTP 200, 1.2s, 1 credit)
# ✗ invalid (auth, HTTP 401) — NOT saved
# Use directly:
surf-search-skill search "claude 4.7 release notes" --max 3
surf-search-skill search "X" --provider brave --mode fast
# Or ask an AI agent:
> make a plan for adding rate limiting to my Express API
# → surf-plan-skill kicks in: reads project, runs surf-search-skill searches,
# asks 3-5 researched questions, writes ~/.claude/plans/<slug>-<ts>.md# One-liner cross-OS install (Linux, macOS, Windows)
npm i -g surf-skill
# That's it — postinstall creates symlinks into all supported harnesses,
# initializes ~/.config/surf/keys.json, and prints a hint.
# On first run, an interactive wizard auto-launches in TTY:
surf-search-skill search "your query"
# → "No keys configured. Launching setup wizard…"
# → prompts for Tavily key #1, #2, …, Parallel key #1, #2, …
# → resumes your command
# In each project where you'll use surf-search-skill (REQUIRED for GH Copilot CLI):
cd path/to/your-project
surf-search-skill project-configYou can also run surf-search-skill setup manually anytime to add more keys.
npm i surf-skillimport { search, extract, research } from 'surf-skill';
// Auto-discovers keys: opts > process.env > .env > ~/.config/surf/keys.json
const r = await search('claude api', { max: 3 });
console.log(r.data.results[0].url);
// Or pass keys explicitly (great for serverless / Next.js API routes)
const r2 = await search('x', {
tavilyKeys: [process.env.MY_TAVILY_1, process.env.MY_TAVILY_2],
depth: 'advanced',
});
// Batch search (single call, N queries, partial-failure tolerant)
const batch = await search(['topic A', 'topic B', 'topic C'], { max: 2 });
// Deep research
const job = await research('compare X vs Y', { model: 'mini' });
console.log(job.data.content);Library works server-side (Node / Next.js API routes / Express). Not for browser bundles — Tavily and Parallel don't enable CORS for browser origins.
You have a Tavily key. Maybe a Parallel one too. Maybe several Tavily keys to spread cost across accounts. Today every agent skill is 1-to-1 with a provider — when a key dies or a provider has an outage, your agent loop breaks.
surf-search-skill is a connector:
- Multi-key per provider. Add as many keys as you want; rotation is
automatic on
401/403/402(auth, insufficient credits) or persistent5xx. Burned keys auto-reset on the first day of the next calendar month (assuming monthly billing). - Provider fallback. If all Tavily keys are burned,
search/extractfail over to Parallel — transparently.crawlandmapstay on Tavily (Parallel doesn't have them).researchdefaults to Parallel first because its Task API is the strongest deep-research surface. - Hot-path memory. The last successful provider/key is remembered in
~/.config/surf/keys.json. The next call starts there — no cold-start cost. - Predictable output.
--jsonreturns the same normalized envelope no matter which provider answered.
The installer configures every harness it can. The user only has to manually configure GitHub Copilot CLI (per project) because it has no global timeout setting.
npm i -g surf-skill
# Installer writes ~/.claude/settings.json:
# { "env": { "BASH_DEFAULT_TIMEOUT_MS": "300000",
# "BASH_MAX_TIMEOUT_MS": "600000" } }The skill becomes available at ~/.claude/skills/surf-search-skill/. In a Claude
Code session, just ask: "search the web for X" — the agent will invoke
surf-search-skill via Bash. For commands that may exceed 5 min, the agent can
pass timeout: 600000 on the Bash call (10 min hard cap), or set
run_in_background: true and monitor via /tasks.
npm i -g surf-skill
# Symlink created at ~/.copilot/skills/ (via ~/.agents/skills/surf-search-skill).Per-project, run inside the project root:
surf-search-skill project-config
# writes .github/copilot-hooks.json with { "timeoutSec": 300 }
# detects .github/ automatically; use --harness copilot --yes to forceWithout this, any surf-search-skill command other than --help, --version,
keys list/add, or search --max 1 will time out. With it, you can use
the full command set up to ~5 min per call.
For longer operations, use Copilot CLI's async pattern: /delegate the
surf-search-skill research-start ... call, then poll with surf-search-skill research-poll <id> from a regular session.
If surf-search-skill detects the agent will likely kill the call before it can
finish, it now aborts early with LikelyAgentTimeout and tells the agent
to suggest surf-search-skill project-config to the user — instead of dying
silently to SIGTERM.
npm i -g surf-skill
# Installer writes ~/.pi/agent/settings.json:
# { "env": { "PI_BASH_DEFAULT_TIMEOUT_SECONDS": "300",
# "PI_BASH_MAX_TIMEOUT_SECONDS": "600" } }The skill becomes available at ~/.pi/agent/skills/surf-search-skill/. Pi reads
the timeout from env, so the settings.json above is enough. For
long-running work, Pi supports subagents with --bg and the await tool.
Also auto-configured by the installer (~/.agents/skills/surf-search-skill/ and
~/.codex/skills/surf-search-skill/). OpenCode gets mcp_timeout + bash.timeout_ms
set to 600 000 ms in ~/.config/opencode/opencode.json.
| Agent | Default bash | Max | After install | Most likely to time out? |
|---|---|---|---|---|
| Claude Code | 120 s | 600 s (hard) | 300 s default | Long crawls > 5 min |
| GitHub Copilot CLI | 30 s | NÃO DOCUMENTADO | unchanged (no global config) | YES — most commands |
| Pi Coding Agent | 120 s | 600 s | 300 s default | Long crawls > 5 min |
| OpenCode | varies | 600 s | 600 s default | Rarely |
If you see timeouts, the order of fixes:
- Use
surf-search-skill research-start+research-pollinstead of syncresearch. - Reduce
--limit/--max/--max-depth. - Bump the per-harness timeout (see the relevant card above).
- Set
SURF_TIMEOUT_MS=300000(caps the HTTP request itself at 5 min).
| Command | What it does | Provider(s) |
|---|---|---|
setup |
Interactive wizard to add keys (TTY) | n/a |
project-config |
Write per-project bash-timeout config | n/a |
search <q> [q2 ...] |
Web search; multiple positional args = batch | tavily, parallel, brave |
extract <url> ... |
Pull markdown from URLs | tavily, parallel |
crawl <url> |
Recursive site crawl | tavily |
map <url> |
Sitemap discovery | tavily |
research <topic> |
Sync deep research (50 s budget) | parallel, tavily |
research-start <topic> |
Start async research | parallel, tavily |
research-poll <id> |
Poll an async research job | (sticky to provider) |
usage --provider <name> |
Provider's usage endpoint | per provider |
cache-clear |
Purge response cache | n/a |
cost [--reset] |
Local credit ledger (per-provider) | n/a |
keys <subcmd> |
add, remove, list, reset, clear |
n/a |
Full reference: skills/surf-search-skill/SKILL.md.
Global flags every command accepts:
--provider <tavily|parallel|brave> Force provider (disables fallback)
--mode <fast|normal|slow> Search tier. Per-provider mapping:
fast = Tavily depth=fast / Brave count=5
normal = default
slow = Tavily depth=advanced / Brave count=20
(Parallel ignores — single mode.)
--no-fallback Keep default provider, no cross-provider fallback
--no-cache Skip response cache
--json Normalized envelope as JSON
--raw-json Raw provider response (bypasses cache)
--confirm-expensive Allow operations estimated > 10 credits
--quiet Silence progress logs (stderr)
surf-search-skill search "X" --mode fast # 5 results / 1 credit Tavily / minimal latency
surf-search-skill search "X" --mode normal # 10 results / default everywhere
surf-search-skill search "X" --mode slow # 20 results / Tavily advanced / deeper signalWant to force a specific provider for a given mode?
surf-search-skill search "X" --provider brave --mode slow # 20 brave results, no fallback
surf-search-skill search "X" --provider tavily --mode fast # Tavily fast tierWhen you need to research multiple angles of the same topic, batch them in a single call. Each positional arg is an independent query:
surf-search-skill search "compare X vs Y" "alternatives to X" "X security issues"- Runs sequentially (avoids rate-limit thrashing on a single key).
- Partial failures are reported inline — the command exits
0if at least one query succeeded. - Total credits and timing surface in the markdown header and
--jsonenvelope. - Progress logs (see below) show
[i/N]per query.
This is the recommended way for an agent to gather multi-source context in one shot, instead of looping with N separate bash calls.
Every operation emits one self-contained line per event to stderr, so both humans and the calling LLM can see what's happening without parsing the main result on stdout.
[surf 17:58:12] ▸ search → tavily (key #0)
[surf 17:58:14] ✓ search tavily 1234ms (2 credits)
[surf 17:58:14] ↻ tavily 429 — backoff 1500ms (attempt 1/3)
[surf 17:58:18] ⚠ tavily key #0 burned (401)
[surf 17:58:18] ▸ search → parallel (key #0)
[surf 17:58:20] ✓ search parallel 2102ms (2 credits)
[surf 17:58:20] ⏱ batch done: 3/3 ok, 0 failed (8200ms, 6 credits)
The format is stable for grep/parse. Use --quiet or SURF_QUIET=1 to
silence (CI, piping, tests). Stdout stays clean either way.
state.json (per provider):
keys: [key0, key1, key2]
current: 1 ← starts here next call
burned: [{ index: 0, reason: "401", at: "2026-05-15..." }]
← auto-reset on the 1st of next month
call flow:
┌─ load state, auto-reset burned ──┐
│ │
└─▶ chain = [last_ok_provider, ─┤
...rest_of_capability_chain]
│
for provider in chain: │
for key in usable_keys(provider):│
try call │
200 ─▶ save last_ok, return │
401/403/402 ─▶ burn key, next│
5xx x3 ─▶ burn key, next │
429 ─▶ backoff, retry │
4xx ─▶ raise (no fallback) │
(no usable keys) ─▶ next provider│
raise AllProvidersExhausted ───────┘
Force a specific provider for debugging:
surf-search-skill search "x" --provider parallel
# 'parallel' fails ⇒ command fails (no fallback when --provider is set)# 1. Wizard (recommended in a TTY)
surf-search-skill setup
# 2. Direct
surf-search-skill keys add --provider tavily tvly-...
surf-search-skill keys add --provider parallel <key>
# 3. Auto-launch in TTY: just run any command without keys
surf-search-skill search "test"
# → "No keys configured. Launching setup wizard…" → prompts → resumes search
# 4. Library mode: env vars / .env / explicit opts (no setup needed)
TAVILY_API_KEY=tvly-... node -e "import('surf-skill').then(m => m.search('x'))"Inspect what was stored (keys are masked):
surf-search-skill keys list
# **Surf keys** (config: ~/.config/surf/keys.json)
# last_ok_provider: `tavily`
# ## tavily (2 keys)
# - [0] tvly-…ab12 *(current)*
# - [1] tvly-…cd34❌ Error [NoProviderAvailable]: operation 'X' requires one of [...]
→ The op needs a key for a provider you haven't configured. In a TTY the
error already suggests surf-search-skill setup. Outside TTY, run
surf-search-skill keys add --provider <name> <key>.
❌ Error [AllProvidersExhausted]: ...
→ Every key on every eligible provider failed. Check surf-search-skill keys list
— if everything is burned, you've either rotated keys mid-billing-cycle
or the providers are down. Run surf-search-skill keys reset to retry.
Command timed out in GH Copilot CLI
→ Run surf-search-skill project-config inside the project root. See the
Copilot CLI card above.
❌ Error [LikelyAgentTimeout]: ...
→ surf-search-skill detected the harness will kill the call before it finishes
(typical on Copilot CLI without per-project config). Run surf-search-skill project-config in the project, then retry. Don't retry the same call
without fixing the timeout first.
❌ Error [KilledBySignal]: surf-search-skill received SIGTERM/SIGINT
→ The harness killed us mid-flight. Same fix as LikelyAgentTimeout. The
SIGTERM handler exists as a fallback — the self-budget check should fire
first when env vars are set.
❌ Error: EXPENSIVE_BLOCKED ...
→ Pass --confirm-expensive after confirming the cost with the user. Or
export SURF_ALLOW_EXPENSIVE=1 for the session.
Refusing sync research with model=pro
→ Use surf-search-skill research-start --model pro ... then surf-search-skill research-poll <id>. Sync research is capped at 50 s on purpose.
.
├── package.json ← name: surf-skill (npm), version 4.0.1, 3 bins
├── README.md ← you're here
├── CHANGELOG.md
├── LICENSE
├── logo.png
├── SKILL.md ← surf-search-skill (search skill, root of pkg)
├── bin/
│ ├── surf.mjs ← interactive setup + key validation
│ ├── surf-search-skill.mjs ← multi-provider web search CLI
│ └── surf-plan-skill.mjs ← planning workflow CLI
├── skills/
│ └── surf-plan-skill/
│ └── SKILL.md ← surf-plan-skill (planning skill)
├── src/
│ ├── index.mjs ← library entry (search/extract/research/...)
│ ├── env.mjs ← key discovery (opts > env > .env > config)
│ ├── plan/ ← plan-file, plans-dir, slug (planning lib)
│ ├── validators/ ← per-provider key validators (live API)
│ ├── lib/
│ │ ├── state.mjs ← ~/.config/surf/keys.json I/O
│ │ ├── cache.mjs ← TTL response cache
│ │ ├── audit.mjs ← audit + usage JSONL
│ │ ├── flags.mjs, cost.mjs, format.mjs
│ │ ├── dispatch.mjs ← provider/key fallback + self-budget
│ │ ├── keys-cmd.mjs ← surf-search-skill keys add/remove/...
│ │ ├── setup.mjs ← interactive onboarding (with validation)
│ │ ├── project-config.mjs ← surf-search-skill project-config
│ │ ├── progress.mjs ← stderr progress events
│ │ ├── check-surf-skill.mjs ← detect companion CLI in PATH
│ │ ├── harness-install.mjs ← cross-OS symlink install for 2 skills
│ │ ├── api/ ← library search/extract/crawl/map/research
│ │ └── providers/
│ │ ├── index.mjs ← capability map (search + 3 providers)
│ │ ├── tavily.mjs
│ │ ├── parallel.mjs
│ │ └── brave.mjs
│ └── install/
│ ├── postinstall.mjs ← cross-OS symlinks + skeleton keys.json
│ └── preuninstall.mjs ← cleanup our symlinks
└── references/
├── tavily-api.md
├── parallel-api.md
├── plan-workflow.md ← deeper docs on the 6-phase planning workflow
└── COSTS.md
- This repository contains no real API keys. The installer only uses placeholders.
- Keys are stored exclusively in
~/.config/surf/keys.json(chmod 600).surf-search-skilldoes not read keys from env at runtime. - The audit log records only
providername and key index, never the key itself.surf-search-skill keys listmasks every key (tvly-…ab12). - The skill never executes content returned from the web — it just prints it.
- Review any skill before installing. Skills can instruct agents to run commands.
MIT.
