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

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 | |
|---|---|
| tools | niche tools denied |
| skills | bundled skills off; /code-review and /simplify on demand |
| connectors | claude.ai MCP connectors off |
| status line | statusbar mod on the mode row |
WebSearch | Exa via the exa-search mod |
curl -fsSL https://raw.githubusercontent.com/enzofrasca/dotclaude/main/setup | bash
Put EXA_API_KEY in ~/.claude/.env
hooks/register.ts 116 lines1// 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