* feat(web-search): add AI/ML API Toolbox as an explicit search backend Add an `aimlapi` web search provider that runs the AI/ML API Toolbox tool `web/search` (POST https://tools.aimlapi.com/v1/tools/run). It reuses the key the AI/ML API model provider already has (AIMLAPI_API_KEY, or the route's OPENAI_API_KEY fallback while OPENAI_BASE_URL is the canonical AI/ML API endpoint), so a user on that provider has nothing to paste. Every run is billed to the user's AI/ML API balance, so the backend is explicit-only: it is not in the auto chain, and an AIMLAPI_API_KEY alone never routes searches through it. Each run carries max_cost_usd 0.025. - Map output.results[] (url/title/snippet/published_at) onto search hits, prefixing the snippet with the publication date. - Send allowed_domains as include_domains; apply both domain lists to the results like the other backends (the tool has no exclude field). - Poll GET /v1/tools/runs/{id} after a 202 until the run ends, within the web search timeout; report is_error runs and HTTP error codes. - Send the key and the X-AIMLAPI-Source / X-AIMLAPI-Partner-ID attribution headers only to tools.aimlapi.com, checked by exact hostname. - /search aimlapi selects it only when the key is present and states the billing; /search key|remove-key aimlapi refuse, because saving AIMLAPI_API_KEY to the global env would also switch the model provider. - Document the backend next to the other search providers. * fix(web-search): never send a proxy profile's key to the AI/ML API Toolbox An aimlapi provider profile that points at the user's own proxy exports the proxy's credential as AIMLAPI_API_KEY, with CLAUDE_CODE_PROVIDER_ROUTE_ID=aimlapi and OPENAI_BASE_URL set to the proxy. The search provider returned AIMLAPI_API_KEY unconditionally, so /search aimlapi would have sent that proxy credential to tools.aimlapi.com. Resolve no key in that case: the backend reports itself as not configured, /search aimlapi refuses the selection, and a search fails with the not-configured error before any request is made. * fix(web-search): link the published AI/ML API setup page from /search The npm package does not ship docs/, so the /search key|remove-key aimlapi message pointed at a path an installed user cannot open. Link the page on GitHub instead and update the matching test expectation. * fix(web-search): refuse redirects on Toolbox requests and pin the cost cap Both Toolbox requests carry the key and the attribution headers, but the hostname check only covered the first URL. Send them with redirect: 'error' so no redirect hop can receive the headers, and assert that in the run and poll tests plus a new test where a redirect fails after a single request. Pin the documented $0.025 max_cost_usd in the request-body test instead of comparing against the exported constant. * fix(web-search): redact echoed AI/ML API credentials in search errors * docs(web-search): describe the requested AI/ML API cost limit * docs(web-search): clarify variable pricing and credential reuse * fix(web-search): honor AI/ML API endpoint alias * test(search): isolate provider credentials per test * fix(web-search): reuse individual AI/ML API pool credentials --------- Co-authored-by: Stan <stan@aimlapi.com> Co-authored-by: Hugo <hugo@aimlapi.com>
23 KiB
Web Search Providers
OpenClaude supports multiple search backends through a provider adapter system.
Exa is the default search backend. With no configuration at all, web search
runs on Exa's keyless free tier. Add an EXA_API_KEY for higher limits; any other
backend you configure with a key is used before the free tier.
Configure with /search
The /search command sets everything below without editing env vars:
| Command | Effect |
|---|---|
/search |
Pick a backend from a list that shows which ones have keys |
/search <backend> |
Select a backend directly (auto, exa, tavily, brave, …); prompts for its key if needed |
/search key [backend] |
Add or replace an API key in a hidden input (defaults to exa) |
/search remove-key [backend] |
Remove a saved API key (defaults to exa) |
/search status |
Show which backend handles searches and what it falls back to |
/search test [query] |
Run a test search through the configured backends |
Selections are saved as WEB_SEARCH_PROVIDER and the backend's key variable in
the env block of ~/.openclaude.json (readable only by you — the same file
/provider uses), and take effect immediately. Keys are never accepted on the
command line.
Precedence: values saved by /search override the same variables exported in
your shell, and are overridden by an env entry for that variable in any
settings.json (user, project, --settings, or managed policy) — /search
tells you when that happens. /search remove-key only removes keys that
/search saved; a key that comes from your shell or a settings file is reported
instead.
Supported Providers
| Provider | Env Var | Auth Header | Method |
|---|---|---|---|
| Custom API | WEB_SEARCH_API |
Configurable | GET/POST |
| Ollama | Active Ollama route or OLLAMA_API_KEY |
Local sign-in / Authorization: Bearer |
POST |
| SearXNG | WEB_PROVIDER=searxng |
— | GET |
WEB_PROVIDER=google + GOOGLE_CSE_ID |
(query param ?key=) |
GET | |
| Brave (preset) | WEB_PROVIDER=brave |
X-Subscription-Token |
GET |
| Brave (adapter) | BRAVE_API_KEY |
X-Subscription-Token |
GET |
| SerpAPI | WEB_PROVIDER=serpapi |
Authorization: Bearer |
GET |
| Firecrawl | FIRECRAWL_API_KEY |
Internal | SDK |
| Tavily | TAVILY_API_KEY |
Authorization: Bearer |
POST |
| Exa (default) | EXA_API_KEY |
x-api-key |
POST |
| Exa free tier | (none — zero-config default) | — | POST |
| AnySearch (explicit only) | ANYSEARCH_API_KEY (optional) |
Authorization: Bearer when set |
POST |
| Keenable (explicit only) | KEENABLE_API_KEY (optional) |
X-API-Key when set |
POST |
| AI/ML API (explicit only, billed) | AIMLAPI_API_KEY (from the AI/ML API provider setup) |
Authorization: Bearer |
POST |
| You.com | YOU_API_KEY |
X-API-Key |
GET |
| Jina | JINA_API_KEY |
Authorization: Bearer |
GET |
| Bing | BING_API_KEY |
Ocp-Apim-Subscription-Key |
GET |
| Mojeek | MOJEEK_API_KEY |
Authorization: Bearer |
GET |
| Linkup | LINKUP_API_KEY |
Authorization: Bearer |
POST |
| DuckDuckGo | (none — last-resort fallback) | — | SDK |
Quick Start
# Exa (default) — works with no setup on the free tier; add a key for higher limits
export EXA_API_KEY=your-exa-key
# Tavily (fast, RAG-ready)
export TAVILY_API_KEY=tvly-your-key
# Brave (independent index, good free tier)
export BRAVE_API_KEY=your-brave-key
# Bing
export BING_API_KEY=your-bing-key
# Self-hosted SearXNG (free, private)
export WEB_PROVIDER=searxng
export WEB_SEARCH_API=https://search.example.com/search
Provider Selection Mode
WEB_SEARCH_PROVIDER controls fallback behavior:
| Mode | Behavior |
|---|---|
auto (default) |
Try all configured providers in order, fall through on failure |
ollama |
Local signed-in Ollama, then hosted Ollama when OLLAMA_API_KEY is set; throws if both fail |
tavily |
Tavily only — throws on failure |
exa |
Exa only — keyed API when EXA_API_KEY is set, otherwise the free tier; throws on failure |
anysearch |
AnySearch only — anonymous without a key, Bearer authentication with one; throws on failure |
keenable |
Keenable only — keyless endpoint without a key, keyed endpoint with one; throws on failure |
aimlapi |
AI/ML API Toolbox web/search only — billed to your AI/ML API balance; throws on failure. Not in the auto chain |
brave |
Brave only — throws on failure |
custom |
Custom API only — throws on failure. Not in the auto chain — must be explicitly selected |
firecrawl |
Firecrawl only — throws on failure |
ddg |
DuckDuckGo only — throws on failure |
native |
Anthropic native / Codex only |
Auto mode priority: exa → ollama → firecrawl → tavily → you → jina → brave → bing → mojeek → linkup → exa free tier → ddg
Each entry is only tried when it is configured (has its key or route). Keyed Exa leads the chain; any other keyed backend still runs before the keyless Exa free tier, and DuckDuckGo is the last resort.
In auto mode, providers with built-in web search (Anthropic first-party,
Vertex, Foundry, Codex) keep using it. Set WEB_SEARCH_PROVIDER=exa to use Exa
there too.
Note: The
customprovider is excluded from theautochain. It is only used whenWEB_SEARCH_PROVIDER=customis explicitly set. This prevents the generic outbound provider from silently becoming the default backend.
AnySearch and Keenable are also explicit-only: selecting them does not change the existing auto chain.
AI/ML API is explicit-only as well. Every search it runs is billed to your AI/ML API
balance, so an AIMLAPI_API_KEY set up for the model provider never makes auto
mode search through it; only /search aimlapi (or WEB_SEARCH_PROVIDER=aimlapi) does.
# Fail loudly if Tavily is down (don't silently switch backends)
export WEB_SEARCH_PROVIDER=tavily
# Try everything, fall through gracefully
export WEB_SEARCH_PROVIDER=auto
Built-in Provider Timeout
Built-in adapter providers use a 15s request timeout so auto mode can fall through when a backend stalls. Override it with:
export WEB_SEARCH_TIMEOUT_SEC=30
Invalid, fractional, zero, negative, or very large values fall back to 15s. Custom API providers keep their separate WEB_CUSTOM_TIMEOUT_SEC setting because self-hosted endpoints may need different budgets.
Provider Request & Response Formats
AnySearch
Run /search anysearch to use its general web search without a key, or /search key anysearch to add an optional key through hidden input. Anonymous access has lower service limits. /search remove-key anysearch removes a key saved by /search; keys from the shell or settings.json remain controlled by those sources.
ANYSEARCH_MAX_RESULTS accepts an integer from 1 to 10 (default 10). Invalid values use 10. General search sends query and max_results to POST https://api.anysearch.com/v1/search. Results come from data.results; OpenClaude reports HTTP and business-code failures separately from an empty result list. A key, when present, is sent only as Authorization: Bearer <key> to that endpoint.
{"query":"search terms","max_results":10}
Keenable
Run /search keenable to use it without a key, or /search key keenable to add an optional key through hidden input. Keyless access is rate-limited per IP; a key lifts those limits. /search remove-key keenable removes a key saved by /search; keys from the shell or settings.json remain controlled by those sources.
Without a key, OpenClaude sends query and max_results (10) to POST https://api.keenable.ai/v1/search/public with an X-Keenable-Title: OpenClaude header. With KEENABLE_API_KEY set, the same body goes to POST https://api.keenable.ai/v1/search with the key in X-API-Key. Result text comes from each result's snippet (or its description when there is no snippet), flattened to one line and capped at 800 characters. Domain filters are applied to the returned hits.
{"query":"search terms","max_results":10}
AI/ML API (Toolbox web/search)
Run /search aimlapi. It uses the AIMLAPI_API_KEY your AI/ML API provider setup
already provides (/provider, openclaude aimlapi topup, or the environment variable —
see AI/ML API Setup); there is nothing to paste. The
route's OPENAI_API_KEY fallback is used too, but only while OPENAI_BASE_URL is the
canonical https://api.aimlapi.com/v1. When the active AI/ML API provider profile points at your own
proxy instead, its key belongs to that proxy and is never sent to the Toolbox: AI/ML API
search then reports itself as not configured.
For a valid comma-separated credential pool, searches use its first key without automatic rotation.
/search does not store or remove that key: an ambient AIMLAPI_API_KEY also selects
AI/ML API as the model provider, so it is managed by the provider setup only. Without the
key, /search aimlapi leaves your settings unchanged and says how to set it up.
Each search runs the Toolbox tool web/search and is billed to your AI/ML API balance.
The tool publishes a $0.0091–$0.0221 price range per run (as of 2026-10); a 10-result
search was charged $0.0091. Every run requests a cost limit with max_cost_usd: 0.025;
enforcement is handled by the Toolbox service.
Request:
POST https://tools.aimlapi.com/v1/tools/run
Authorization: Bearer $AIMLAPI_API_KEY
X-AIMLAPI-Source: agent/openclaude
X-AIMLAPI-Partner-ID: <OpenClaude partner id>
Content-Type: application/json
{"tool": "web/search",
"input": {"query": "search terms", "max_results": 10, "include_domains": ["example.com"]},
"max_cost_usd": 0.025, "wait_ms": 13000}
allowed_domains are sent as include_domains; the tool has no exclude field, so
blocked_domains are applied to the results. Both lists are also enforced on the
results like every other backend. wait_ms is the search timeout minus two seconds,
clamped to 1000–30000 ms (13000 ms with the default WEB_SEARCH_TIMEOUT_SEC=15).
This server-side wait is separate from the overall search timeout, which also covers polling.
Response: 200 with the finished run, or 202 while it is still queued — OpenClaude
then polls GET https://tools.aimlapi.com/v1/tools/runs/{id} (free) after
next_poll_hint_ms until the run is completed, failed, stopped or timed_out, all
within the search timeout. A run with "is_error": true is reported with its
error.message.
{
"id": "KsFgaANj50jSrzgHgNCLq",
"status": "completed",
"is_error": false,
"untrusted": true,
"output": {
"results": [
{
"url": "https://nodejs.org/en/blog/release",
"title": "Node.js",
"snippet": "Node.js 24.22.0 (LTS) ...",
"published_at": "2026-10-14T15:26:48.000Z"
}
]
},
"cost": {"quoted_usd": 0.0091, "actual_usd": 0.0091, "credits": 18200, "over_max": false}
}
Each result becomes a search hit: title, url, and the snippet as the description,
prefixed with the publication date when published_at is present. Results are web
content ("untrusted": true) and are passed to the model as search results, the same as
every other backend's — never as instructions. The key and the attribution headers are
sent only to tools.aimlapi.com, which is not configurable.
Ollama
When the active route resolves to Ollama, OpenClaude first calls the configured server's signed-in proxy:
POST http://localhost:11434/api/experimental/web_search
Content-Type: application/json
{"query":"search terms","max_results":10}
If that request fails and OLLAMA_API_KEY is set, it retries the hosted API with Authorization: Bearer $OLLAMA_API_KEY. The key is never sent to the local server.
POST https://ollama.com/api/web_search
Authorization: Bearer $OLLAMA_API_KEY
Content-Type: application/json
Both endpoints return structured results containing title, url, and content. Use WEB_SEARCH_PROVIDER=ollama to select only this local-to-hosted Ollama chain; in auto mode, another configured search backend is tried if Ollama search fails.
Tavily
export TAVILY_API_KEY=tvly-your-key
Request:
POST https://api.tavily.com/search
Authorization: Bearer tvly-your-key
Content-Type: application/json
{"query": "search terms", "max_results": 10, "include_answer": false}
Response:
{
"results": [
{
"title": "Result Title",
"url": "https://example.com/page",
"content": "Full text snippet from the page...",
"score": 0.95
}
]
}
Exa (default)
export EXA_API_KEY=your-exa-key # optional — the free tier works without it
| Env var | Default | Effect |
|---|---|---|
EXA_API_KEY |
— | Use the keyed Exa API (higher limits) |
EXA_SEARCH_TYPE |
auto |
auto, instant, fast, deep-lite, deep, or deep-reasoning (keyed API only; the deep types may need a higher WEB_SEARCH_TIMEOUT_SEC) |
EXA_NUM_RESULTS |
15 |
Results per search, 1–50 (keyed API and free tier) |
EXA_FREE_TIER |
on | Set to 0 to never use the keyless free tier |
Request:
POST https://api.exa.ai/search
x-api-key: your-exa-key
Content-Type: application/json
{"query": "search terms", "numResults": 15, "type": "auto", "contents": {"highlights": true}}
allowed_domains / blocked_domains are sent as includeDomains / excludeDomains.
Response:
{
"results": [
{
"title": "Result Title",
"url": "https://example.com/page",
"highlights": ["The most query-relevant excerpt...", "Another excerpt..."],
"highlightScores": [0.91, 0.84]
}
]
}
Exa Free Tier (Zero-Config Default)
No configuration needed. When no EXA_API_KEY is set, OpenClaude calls Exa's
hosted endpoint, which serves a free tier with a per-IP requests-per-second limit
and a daily quota. Concurrent searches are spaced automatically. When the limit is
hit, auto mode falls through to DuckDuckGo and skips the free tier for the next
few minutes; in exa mode (and /search test) the error tells you to add a key
with /search key exa.
Request (MCP streamable HTTP, JSON-RPC):
POST https://mcp.exa.ai/mcp
Content-Type: application/json
Accept: application/json, text/event-stream
x-exa-source: openclaude
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "web_search_exa",
"arguments": {"query": "search terms", "objective": "...", "numResults": 15}}}
The reply is plain text (Title: / URL: / Highlights: blocks) inside an SSE
message event. allowed_domains become site: operators in the query, and both
domain lists are also enforced on the parsed results.
Opt out (searches never go to Exa without a key):
export EXA_FREE_TIER=0
You.com
export YOU_API_KEY=your-you-key
Request:
GET https://api.ydc-index.io/v1/search?query=search+terms
X-API-Key: your-you-key
Response:
{
"results": {
"web": [
{
"title": "Result Title",
"url": "https://example.com/page",
"snippets": ["First snippet from the page...", "Second snippet..."],
"description": "Page description"
}
]
}
}
Jina
export JINA_API_KEY=your-jina-key
Request:
GET https://s.jina.ai/?q=search+terms
Authorization: Bearer your-jina-key
Accept: application/json
Response:
{
"data": [
{
"title": "Result Title",
"url": "https://example.com/page",
"description": "Snippet from the page..."
}
]
}
Bing
export BING_API_KEY=your-bing-key
Request:
GET https://api.bing.microsoft.com/v7.0/search?q=search+terms&count=10
Ocp-Apim-Subscription-Key: your-bing-key
Response:
{
"webPages": {
"value": [
{
"name": "Result Title",
"url": "https://example.com/page",
"snippet": "A short excerpt from the page...",
"displayUrl": "example.com/page"
}
]
}
}
Mojeek
export MOJEEK_API_KEY=your-mojeek-key
Request:
GET https://www.mojeek.com/search?q=search+terms&fmt=json
Authorization: Bearer your-mojeek-key
Response:
{
"response": {
"results": [
{
"title": "Result Title",
"url": "https://example.com/page",
"snippet": "Excerpt from the page..."
}
]
}
}
Linkup
export LINKUP_API_KEY=your-linkup-key
Request:
POST https://api.linkup.so/v1/search
Authorization: Bearer your-linkup-key
Content-Type: application/json
{"q": "search terms", "search_type": "standard"}
Response:
{
"results": [
{
"name": "Result Title",
"url": "https://example.com/page",
"snippet": "A short description of the result..."
}
]
}
SearXNG (Built-in Preset)
export WEB_PROVIDER=searxng
export WEB_SEARCH_API=https://search.example.com/search
Request:
GET https://search.example.com/search?q=search+terms
Response:
{
"results": [
{
"title": "Result Title",
"url": "https://example.com/page",
"content": "Snippet from the page...",
"engine": "google"
}
]
}
Google Custom Search (Built-in Preset)
⚠️ Sunset 2027-01-01. Google has announced the Custom Search JSON API will be discontinued and is closed to new customers. Use Brave/Tavily/Exa for new setups.
export WEB_PROVIDER=google
export WEB_KEY=your-google-api-key
export GOOGLE_CSE_ID=your-programmable-search-engine-id
GOOGLE_CSE_ID is the cx value from your Programmable Search Engine — both
the API key and the engine ID are required.
Request:
GET https://www.googleapis.com/customsearch/v1?q=search+terms&key=your-google-api-key&cx=your-engine-id
Response:
{
"items": [
{
"title": "Result Title",
"link": "https://example.com/page",
"snippet": "A short excerpt...",
"displayLink": "example.com"
}
]
}
Brave (First-Class Adapter)
The recommended way to use Brave — auto-detected, joins the auto fallback chain.
export BRAVE_API_KEY=your-brave-key
Request:
GET https://api.search.brave.com/res/v1/web/search?q=search+terms&count=15
X-Subscription-Token: your-brave-key
Brave (Built-in Preset, alternative)
For users who prefer the generic preset path. Functionally equivalent to the adapter above; either works.
export WEB_PROVIDER=brave
export WEB_KEY=your-brave-key
Response:
{
"web": {
"results": [
{
"title": "Result Title",
"url": "https://example.com/page",
"description": "Page description..."
}
]
}
}
SerpAPI (Built-in Preset)
export WEB_PROVIDER=serpapi
export WEB_KEY=your-serpapi-key
Request:
GET https://serpapi.com/search.json?q=search+terms
Authorization: Bearer your-serpapi-key
Response:
{
"organic_results": [
{
"title": "Result Title",
"link": "https://example.com/page",
"snippet": "A short excerpt...",
"displayed_link": "example.com"
}
]
}
DuckDuckGo (Last-Resort Fallback)
No configuration needed. Uses the duck-duck-scrape npm package. In auto mode it
runs only after every configured backend (including the Exa free tier) has failed.
# Set as explicit-only backend
export WEB_SEARCH_PROVIDER=ddg
Custom API Configuration
Standard GET
GET https://api.example.com/search?q=hello
export WEB_SEARCH_API=https://api.example.com/search
export WEB_QUERY_PARAM=q
Query in URL Path
GET https://api.example.com/v2/search/hello
export WEB_URL_TEMPLATE=https://api.example.com/v2/search/{query}
POST with Custom Body
POST https://api.example.com/v1/query
Content-Type: application/json
{"input": {"text": "hello"}}
export WEB_SEARCH_API=https://api.example.com/v1/query
export WEB_METHOD=POST
export WEB_BODY_TEMPLATE='{"input":{"text":"{query}"}}'
Extra Static Params
export WEB_PARAMS='{"lang":"en","count":"10"}'
Auth
API keys are sent in HTTP headers, never in query strings.
# Default: Authorization: Bearer <key>
export WEB_KEY=your-key
# Custom header
export WEB_AUTH_HEADER=X-Api-Key
export WEB_AUTH_SCHEME=""
# Extra headers
export WEB_HEADERS="X-Tenant: acme; Accept: application/json"
Response Parsing
The tool auto-detects many response formats:
{ "results": [{ "title": "...", "url": "..." }] } // flat array
{ "items": [{ "title": "...", "link": "..." }] } // Google-style
{ "results": { "engine": [{ "title": "...", "url": "..." }] } } // nested map
[{ "title": "...", "url": "..." }] // bare array
Field name aliases: title/headline/name, url/link/href, description/snippet/content
For deeply nested responses:
export WEB_JSON_PATH=response.payload.results
Retry
Failed custom-provider requests (network errors, 5xx) are retried once after 500ms. Client errors (4xx) are not retried. Custom requests have a default 120s timeout.
Custom Provider Security Guardrails
The custom provider enforces the following guardrails by default:
| Guardrail | Default | Override |
|---|---|---|
| HTTPS-only | ✅ | WEB_CUSTOM_ALLOW_HTTP=true |
| Block private IPs / localhost | ✅ | WEB_CUSTOM_ALLOW_PRIVATE=true |
| Header allowlist | ✅ | WEB_CUSTOM_ALLOW_ARBITRARY_HEADERS=true |
| Max POST body | 300 KB | WEB_CUSTOM_MAX_BODY_KB=<kb> |
| Request timeout | 120s | WEB_CUSTOM_TIMEOUT_SEC=<seconds> |
| Audit log (one-time warning) | ✅ | — |
Self-hosted SearXNG example
export WEB_PROVIDER=searxng
export WEB_SEARCH_API=https://search.mydomain.com/search
export WEB_CUSTOM_ALLOW_PRIVATE=true # needed if SearXNG is on a private IP
Header allowlist
By default only these headers are permitted:
accept, accept-encoding, accept-language, authorization, cache-control, content-type, if-modified-since, if-none-match, ocp-apim-subscription-key, user-agent, x-api-key, x-subscription-token, x-tenant-id
Adding a Provider
- Create
providers/myprovider.ts:
import type { SearchInput, SearchProvider } from './types.js'
import { applyDomainFilters, type ProviderOutput } from './types.js'
export const myProvider: SearchProvider = {
name: 'myprovider',
isConfigured() { return Boolean(process.env.MYPROVIDER_API_KEY) },
async search(input: SearchInput): Promise<ProviderOutput> {
const start = performance.now()
// ... call API, map to SearchHit[] ...
return {
hits: applyDomainFilters(hits, input),
providerName: 'myprovider',
durationSeconds: (performance.now() - start) / 1000,
}
},
}
- Register in
providers/index.ts— add import and push toALL_PROVIDERS.