Short-circuits repeated WebFetch calls for the same URL and prompt within a session, answering { result } from cache without a network round trip. A tool.call…

Short-circuits repeated WebFetch calls for the same URL + prompt within a session. On a cache hit the hook answers { result } itself without calling next (no network call); on a miss it awaits the real fetch, stores the tool's record and returns what came back.
Only the tool's own record (result) is cached, never core's ref: that number names the messages core produced for one specific call and must not be replayed on another.
A long page read in pieces (WebFetch's offset, Claude Code 2.1.290+) is cached piece by piece: a read further down the page never gets the first page back.
/webfetch-cache on|off toggles it for the session (so meus-mods can control it too); turning it off also clears the in-memory cache.
ttlSeconds: number how long an entry stays fresh (default 900)
maxEntries: number cache size (default 200)
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": { "webfetch-cache@skills-dir": { "options": { } } } }
npx claude-code-templates@latest --mod productivity/webfetch-cache
claude
It is written to .claude/skills/webfetch-cache/, which Claude Code auto-loads as webfetch-cache@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/webfetch-cache. claude plugin validate .claude/skills/webfetch-cache 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
Vendored from davila7/claude-code-templates (cli-tool/components/mods/productivity/webfetch-cache), MIT licensed. Added the isEnabled toggle above so it fits the on/off convention of every other mod in this repo.
hooks/register.ts 74 lines1// Vendored from davila7/claude-code-templates (cli-tool/components/mods/productivity/webfetch-cache),
2// MIT licensed. Added: an isEnabled toggle (/webfetch-cache on|off) so meus-mods can control it.
3import { atom, read, update } from 'claude-code'
4import type { Register, ToolCallResult } from 'claude-code'
5
6const isEnabledRef = atom({ plugin: 'webfetch-cache', key: 'isEnabled' } as const, true)
7
8type WebFetchRecord = Extract<ToolCallResult<'WebFetch'>, { result: unknown }>['result']
9
10interface Entry { result: WebFetchRecord; storedAt: number }
11
12// Module state lives for the session (a plugin's module is one worker).
13const cache = new Map<string, Entry>()
14
15export const register: Register = (on, options) => {
16 const ttlMs = (typeof options.ttlSeconds === 'number' ? options.ttlSeconds : 900) * 1000
17 const maxEntries = typeof options.maxEntries === 'number' ? options.maxEntries : 200
18
19 on('session.start', async ($, e, next) => {
20 const saved = await $.store.get('isEnabled').catch(() => undefined)
21 await update($, isEnabledRef, () => (typeof saved === 'boolean' ? saved : isEnabledRef.initial))
22 await $.command.register({
23 name: 'webfetch-cache',
24 description: 'Liga/desliga o cache de WebFetch repetido na mesma sessão.',
25 argumentHint: 'on|off',
26 })
27 return next(e)
28 })
29
30 on('command.run', { command: 'webfetch-cache' }, async ($, e) => {
31 const arg = e.args.trim().toLowerCase()
32 if (arg !== 'on' && arg !== 'off') {
33 const isEnabled = await read($, isEnabledRef)
34 return { text: `webfetch-cache esta ${isEnabled ? 'ativado' : 'desativado'}. Uso: /webfetch-cache on · /webfetch-cache off` }
35 }
36 await update($, isEnabledRef, () => arg === 'on')
37 await $.store.set('isEnabled', arg === 'on') // $.state is per session; the store survives a restart
38 if (arg === 'off') cache.clear()
39 return { text: `webfetch-cache ${arg === 'on' ? 'ativado' : 'desativado'}.` }
40 })
41
42 on('tool.call', { tool: 'WebFetch' }, async ($, e, next) => {
43 if (!e.url || !(await read($, isEnabledRef))) return next(e)
44 // WebFetch reads a long page in pieces (`offset`, Claude Code 2.1.290+): each piece is its own entry,
45 // or a read past the first page would get the first page back. Not in the typings until they are regenerated (TODO: drop the cast then).
46 const offset = (e as { offset?: unknown }).offset
47 const key = `${e.url}\n${e.prompt}\n${typeof offset === 'number' ? offset : 0}`
48 const now = Date.now()
49
50 const hit = cache.get(key)
51 if (hit && now - hit.storedAt < ttlMs) {
52 const age = Math.round((now - hit.storedAt) / 1000)
53 $.ui.log(`[webfetch-cache] hit for ${e.url} (${age}s old)`)
54 // Nothing below this hook runs: no network call.
55 return {
56 result: hit.result,
57 context: [`webfetch-cache served this result from a ${age}s-old cache entry for the same URL, prompt and offset.`],
58 }
59 }
60
61 const outcome = await next(e)
62
63 // Do not cache a denial or an errored fetch.
64 if (outcome.deny === undefined && !outcome.isError) {
65 if (cache.size >= maxEntries) {
66 const oldest = cache.keys().next().value
67 if (oldest !== undefined) cache.delete(oldest)
68 }
69 cache.set(key, { result: outcome.result, storedAt: now })
70 }
71 return outcome
72 })
73}
74types/index.d.ts 12 lines1export type WebfetchCacheState = {
2 isEnabled: boolean
3}
4
5declare module 'claude-code' {
6 interface PluginState {
7 'webfetch-cache': {
8 isEnabled: boolean
9 }
10 }
11}
12