Overrides the built-in WebSearch tool and routes queries to the Exa search API through $.http.fetch, answering in WebSearch's own result shape, with automatic…

Overrides the built-in WebSearch tool and routes the query to the Exa search API through $.http.fetch. On success the hook answers { result } in WebSearch's own output shape without calling next (the built-in search never runs); with no key configured, or when Exa fails, it falls back to the built-in search.
The API key comes from the plugin's options (userConfig "exaApiKey"). Never hardcode it in this file.
exaApiKey: string Exa API key (required to activate)
numResults: number results per query (default 8)
type: string "auto" | "neural" | "keyword" (default "auto")
Declared in .claude-plugin/plugin.json (userConfig). Set them in /config, in user settings (~/.claude/settings.json, not project settings), with --settings <file> or in managed settings:
{ "pluginConfigs": { "websearch-to-exa@skills-dir": { "options": { } } } }
npx claude-code-templates@latest --mod integrations/websearch-to-exa
claude
It is written to .claude/skills/websearch-to-exa/, which Claude Code auto-loads as websearch-to-exa@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/websearch-to-exa. claude plugin validate .claude/skills/websearch-to-exa prints every event it hooks and every $ call it makes.
Requirements. Mods are on by default in Claude Code 2.1.287+. Typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
hooks/websearch-to-exa.ts 89 lines1/**
2 * websearch-to-exa — Claude Mod
3 *
4 * Overrides the built-in WebSearch tool and routes the query to the Exa
5 * search API through `$.http.fetch`. On success the hook answers `{ result }`
6 * in WebSearch's own output shape without calling `next` (the built-in search
7 * never runs); with no key configured, or when Exa fails, it falls back to
8 * the built-in search.
9 *
10 * The API key comes from the plugin's options (userConfig "exaApiKey").
11 * Never hardcode it in this file.
12 *
13 * Needs Claude Code >= 2.1.287. Typed
14 * against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
15 *
16 * Options:
17 * exaApiKey: string Exa API key (required to activate)
18 * numResults: number results per query (default 8)
19 * type: string "auto" | "neural" | "keyword" (default "auto")
20 */
21import type { Register } from 'claude-code'
22
23interface ExaResult { title?: string; url: string; text?: string; highlights?: string[]; publishedDate?: string }
24
25export const register: Register = (on, options) => {
26 const apiKey = typeof options.exaApiKey === 'string' ? options.exaApiKey : ''
27 const numResults = typeof options.numResults === 'number' ? options.numResults : 8
28 const searchType = typeof options.type === 'string' ? options.type : 'auto'
29
30 on('tool.call', { tool: 'WebSearch' }, async ($, e, next) => {
31 if (!apiKey) {
32 $.ui.log('[websearch-to-exa] no exaApiKey configured, using built-in WebSearch')
33 return next(e)
34 }
35 if (!e.query) return next(e)
36
37 const startedAt = Date.now()
38 try {
39 const response = await $.http.fetch('https://api.exa.ai/search', {
40 method: 'POST',
41 headers: { 'content-type': 'application/json', 'x-api-key': apiKey },
42 body: JSON.stringify({
43 query: e.query,
44 type: searchType,
45 numResults,
46 includeDomains: e.allowed_domains,
47 excludeDomains: e.blocked_domains,
48 contents: { highlights: { maxCharacters: 400 } },
49 }),
50 })
51
52 if (!response.ok) throw new Error(`Exa responded ${response.status}`)
53 const data = JSON.parse(response.text) as { results?: ExaResult[] }
54 const results = data.results ?? []
55
56 $.ui.log(`[websearch-to-exa] ${results.length} results for "${e.query}"`)
57
58 // Answer in the WebSearch tool's own output shape: a hits block plus a
59 // text block carrying the snippets, so the model reads it like a built-in search.
60 const snippets = results
61 .map((r, i) => {
62 const snippet = (r.highlights ?? []).join(' … ') || (r.text ?? '').slice(0, 400)
63 const date = r.publishedDate ? ` (${r.publishedDate.slice(0, 10)})` : ''
64 return `${i + 1}. ${r.title ?? r.url}${date}\n ${r.url}\n ${snippet}`
65 })
66 .join('\n')
67
68 return {
69 result: {
70 query: e.query,
71 results: [
72 {
73 tool_use_id: e.tool_use_id ?? '',
74 content: results.map((r) => ({ title: r.title ?? r.url, url: r.url })),
75 },
76 `Results from Exa (${searchType} search):\n${snippets}`,
77 ],
78 durationSeconds: (Date.now() - startedAt) / 1000,
79 searchCount: 1,
80 },
81 }
82 } catch (err) {
83 const message = err instanceof Error ? err.message : String(err)
84 $.ui.log(`[websearch-to-exa] Exa failed (${message}), falling back to built-in WebSearch`)
85 return next(e)
86 }
87 })
88}
89