# SPEC-015 — Web search (Exa) A `web_search` function tool for general "look it up on the internet" questions the other tools do not cover: IGDB is games, the Codex is Adeptus Mechanicus lore, the news store is the configured feeds, and `fetch_url` needs a URL the user already has. Web search fills the gap and pairs with `fetch_url` (search → pick a link → read it). Results are external text and are sanitized (SAF-03); the Exa key is a host secret, never in the repo. Active only when `enable-web-search = true` and a key is present. ### WEB-01 — web_search is offered as a tool (coverage: test) When `enable-web-search` is true **and** an Exa key is available (`exa-api-key` in config, else `EXA_API_KEY` env), the chat call's `tools` list includes a `web_search` function (`query` string, optional `num_results`). With the flag off or no key it is absent. ### WEB-02 — Results are reduced and sanitized (coverage: test) Each Exa result becomes `{title, url, snippet, published}`; `title` and `snippet` pass through `sanitize_external_text` (snippet capped at `web-snippet-chars`, default 400) so a web page can neither inject an `@everyone` nor smuggle control characters into the prompt. ### WEB-03 — Result count is bounded (coverage: test) `num_results` is clamped to 1..`MAX_RESULTS` (10) before the request, so neither a huge fan-out nor a zero/negative count reaches the API. ### WEB-04 — Missing key and API failure are reported, not raised (coverage: test) With no key the tool returns an `{error: ...}` result without a network call. A request that raises (network, non-2xx, bad JSON) is logged and returns an `{error: ...}` dict — `search` never raises into the loop. ### WEB-05 — Searches are metered per user (coverage: test) Each `web_search` increments a per-user daily counter; over `web-daily-per-user` (default 30) the tool refuses with an error result without calling the API. The budget gate (SAF-04) still applies to the surrounding model calls.