SLOPSHOPPER

websearch-to-exa

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…

newguardnetwork
A shopper browsing a rack in a slop shop
README

websearch-to-exa

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.

Options

  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": { } } } }

Install

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

Source 1 files
hooks/websearch-to-exa.ts 89 lines
1/**
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