SLOPSHOPPER

exa-search

Answers WebSearch with Exa: ranked results with highlighted excerpts; falls back to the native search

newguardnetwork
v0.1.0no licenseupdated 2026-10-24enzofrasca/dotclaude/mods/exa-search
A shopper browsing a rack in a slop shop
README

dotclaude

Minimal Claude Code setup.

The point is a small always-on context, so the window goes to the task instead of the harness.

vs stock Claude Code
toolsniche tools denied
skillsbundled skills off; /code-review and /simplify on demand
connectorsclaude.ai MCP connectors off
status linestatusbar mod on the mode row
WebSearchExa via the exa-search mod

Setup & update

curl -fsSL https://raw.githubusercontent.com/enzofrasca/dotclaude/main/setup | bash

Put EXA_API_KEY in ~/.claude/.env

Source 1 files
hooks/register.ts 116 lines
1// exa-search — answers Claude's WebSearch with Exa instead of the native search.
2// Results keep WebSearch's shape: the links, then the highlighted excerpts as
3// text, so Claude reads them like any search. WebFetch stays native.
4//
5// The key comes from EXA_API_KEY, or from EXA_API_KEY=... in ~/.claude/.env
6// (untracked). Without a key, or when Exa fails, the native search runs.
7// Request shape per https://exa.ai/docs/reference/search-api-guide-for-coding-agents
8
9const NUM_RESULTS = 10
10// Highlight text per result: ten results stay around 2k tokens
11const MAX_CHARACTERS = 800
12
13let key = ''
14
15// KEY=value from a .env file; quotes stripped, trailing comments dropped
16function fromDotenv(text: string, name: string) {
17  for (const line of text.split(/\r?\n/)) {
18    const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line)
19    if (!m || m[1] !== name) continue
20    const v = m[2].trim()
21    return /^(["']).*\1$/.test(v) ? v.slice(1, -1) : v.replace(/\s+#.*$/, '')
22  }
23  return ''
24}
25
26async function loadKey($) {
27  key = (await $.env.get('EXA_API_KEY')) ?? ''
28  if (key) return
29  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
30  const text = await $.fs.read(`${home}/.claude/.env`).catch(() => '')
31  key = fromDotenv(text, 'EXA_API_KEY')
32}
33
34// An id shaped like the native search's own server tool ids
35function toolUseId() {
36  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
37  let id = 'srvtoolu_01'
38  for (let i = 0; i < 22; i++) id += chars[Math.floor(Math.random() * chars.length)]
39  return id
40}
41
42function parseResults(text: string) {
43  try {
44    return JSON.parse(text)?.results ?? null
45  } catch {
46    return null
47  }
48}
49
50function excerpts(query: string, hits: any[]) {
51  if (hits.length === 0) return `No results for: ${query}`
52  const lines = ['Excerpts below. Answer from them when sufficient; WebFetch a URL only when a concrete detail is missing.', '']
53  hits.forEach((h, i) => {
54    lines.push(`## ${i + 1}. ${h.title ?? 'Untitled'}`, h.url ?? '')
55    if (h.publishedDate) lines.push(`Published: ${h.publishedDate.slice(0, 10)}`)
56    lines.push('')
57    // Exa treats maxCharacters as a target, so cut here too
58    let room = MAX_CHARACTERS
59    const hl: string[] = []
60    for (const s of (h.highlights ?? []).filter((s) => typeof s === 'string' && s.trim())) {
61      if (room <= 0) break
62      const t = s.trim()
63      hl.push(t.length > room ? t.slice(0, room).trimEnd() + '…' : t)
64      room -= t.length
65    }
66    if (hl.length) for (const s of hl) lines.push(`> ${s}`, '')
67    else lines.push('_(no excerpt; WebFetch this URL if it looks relevant)_', '')
68  })
69  return lines.join('\n')
70}
71
72export function register(on) {
73  on('session.start', async ($, e, next) => {
74    await loadKey($)
75    return next(e)
76  })
77
78  on('tool.call', { tool: 'WebSearch' }, async ($, e, next) => {
79    // A key added after the session started is picked up on the next search
80    if (!key) await loadKey($)
81    if (!key) return next(e)
82
83    const started = Date.now()
84    const body: Record<string, unknown> = {
85      query: e.query,
86      // "extended" asks for a thorough search: Exa's multi-step deep search
87      type: e.mode === 'extended' ? 'deep' : 'auto',
88      numResults: NUM_RESULTS,
89      contents: { highlights: { maxCharacters: MAX_CHARACTERS } },
90    }
91    if (e.allowed_domains?.length) body.includeDomains = e.allowed_domains
92    if (e.blocked_domains?.length) body.excludeDomains = e.blocked_domains
93
94    const res = await $.http.fetch('https://api.exa.ai/search', {
95      method: 'POST',
96      headers: { 'x-api-key': key, 'Content-Type': 'application/json' },
97      body: JSON.stringify(body),
98    }).catch(() => null)
99    const hits = res?.ok ? parseResults(res.text) : null
100    // Exa down, rate-limited or a bad key: let the native search answer
101    if (!Array.isArray(hits)) return next(e)
102
103    return {
104      result: {
105        query: e.query,
106        results: [
107          { tool_use_id: toolUseId(), content: hits.map((h) => ({ title: h.title ?? 'Untitled', url: h.url ?? '' })) },
108          excerpts(e.query, hits),
109        ],
110        durationSeconds: (Date.now() - started) / 1000,
111        searchCount: 1,
112      },
113    }
114  })
115}
116