Web search

In one line
Resolves web_search and web_extract tool calls against a configured search backend.
You need
The plugin-web-search feature and an API key for the configured backend.
Read this if
You are registering WebSearchPlugin or choosing a WebSearchProvider.

Feature and entry type

ItemValue
Feature flagplugin-web-search (enables dep:reqwest, dep:tokio, reqwest/rustls)
Entry typeWebSearchPlugin
Supporting typesWebSearchConfig, WebSearchProvider, SearchResult
Plugin name"web-search"
Attachmentregister_plugin
Peer featuresNone; the feature enables its own TLS backend, so no provider feature is needed for HTTPS
NeedsAn API key for the configured backend

Hooks

WebSearchPlugin overrides on_stream_chunk only. A ToolCall named web_search or web_extract is replaced with a ToolResult. Any other block passes through untouched.

  • web_search takes a required, non-blank query. The output is a JSON array of SearchResult objects, {title, url, snippet}. A body with no results gives [].
  • web_extract takes a required, non-blank url. The output is the page text. Only Firecrawl exposes a scrape endpoint, so on Tavily and DeepSeek Web Search the call fails with PluginError::NotSupported before any network call.

Failures never fail the hook. The error text becomes the ToolResult output, so the model can react to it in the conversation. For example: internal plugin error: web search returned HTTP 401 Unauthorized: ....

Each call runs on a short-lived OS thread with its own current-thread Tokio runtime, and the hook waits on a std channel. The stream pauses until the result is ready, then the thread ends. This is safe on a current-thread runtime and on a runtime worker.

The plugin also exposes search(query), extract_page(url) and build_request(query). build_request returns the method, URL, headers and body without doing any I/O.

Configuration

WebSearchConfig defaults:

KeyTypeDefaultMeaning
providerWebSearchProviderFirecrawlThe search backend.
api_keyString""Backend API key; set it for live calls.
base_urlOption<String>NoneOverrides the backend base URL.
max_resultsusize5Results requested per search.

WebSearchPlugin::new(config) builds a reqwest::Client with a 30 second timeout. It does not validate api_key.

Backends

WebSearchProviderSearch routeAuthDefault base URLExtraction
FirecrawlPOST {base}/v1/searchAuthorization: Bearerhttps://api.firecrawl.devPOST {base}/v1/scrape
TavilyPOST {base}/searchapi_key in the bodyhttps://api.tavily.comNone
DeepSeekWebSearchPOST {base}/web-searchAuthorization: Bearerhttps://api.deepseek.comNone

Request bodies and response fields per backend:

  • Firecrawl sends {query, limit} and reads data[].{title, url, description}, with description as the snippet.
  • Tavily sends {api_key, query, max_results} and reads results[].{title, url, content}, with content as the snippet.
  • DeepSeek Web Search sends {query, max_results} and reads results[].{title, url, snippet}.
  • An item without a string title or url is dropped; a missing snippet becomes the empty string.
  • Firecrawl extraction sends {url} and returns data.markdown, or data.content when there is no markdown. A response with neither is an error.

Caps and at-cap policy

No cap on growth: the plugin holds its configuration and an HTTP client, and nothing accumulates between calls.

max_results bounds each search, not the plugin. The plugin passes it to the backend as limit or max_results and does not truncate the response. The usage gauge is none.

Errors

The hook returns no error. Each variant below becomes ToolResult output text. search and extract_page return them to a direct caller.

  • PluginError::Validation when query or url is missing, not a string, or blank, and when a response body is not valid JSON.
  • PluginError::NotSupported for web_extract on Tavily or DeepSeek Web Search.
  • PluginError::Internal for a transport failure, a non-2xx HTTP status (the message carries the status and body), a failure to build the worker runtime, a worker thread that panics or returns nothing, and a Firecrawl scrape response with no extractable content.

Example

examples/web_search.rs searches through Firecrawl. It needs a llama-server on port 1234 with the demo model loaded and CUCA_SEARCH_API_KEY holding a Firecrawl key; without the key it prints one line and exits. CUCA_SEARCH_BASE_URL overrides the backend endpoint. CUCA_BASE_URL and CUCA_MODEL retarget the model at any OpenAI-compatible server.

let config = WebSearchConfig {
    provider: WebSearchProvider::Firecrawl,
    api_key,
    base_url: std::env::var("CUCA_SEARCH_BASE_URL").ok(),
    max_results: 3,
};
let web_search = Arc::new(WebSearchPlugin::new(config));
let client = CucaClient::builder()
    .with_provider(ProviderEndpoint::LlamaCpp)
    .with_base_url(base_url.clone())
    .register_plugin(Arc::clone(&web_search) as Arc<dyn CucaPlugin>)
    .build()?;
cargo run --example web_search --features "provider-llamacpp plugin-web-search"
Backend Firecrawl, at most 3 results per search

Turn 1: the model searches, and the pipeline pauses for the HTTP call
  tool result: internal plugin error: web search returned HTTP 401 Unauthorized: {"success":false,"error":"Unauthorized: Invalid token"}
  thinking blocks: 60

No results, so there is no second turn
  a valid key makes this a JSON array of {title, url, snippet} objects

The thinking-block count depends on the model; this output came from google/gemma-4-12b-qat.

↑↓ to navigate ↵ to open esc to close