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.
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
hooks/webfetch-cache.ts 65 lines1/**
2 * webfetch-cache — Claude Mod
3 *
4 * Short-circuits repeated WebFetch calls for the same URL + prompt within a
5 * session. On a cache hit the hook answers `{ result }` itself without calling
6 * `next` (no network call); on a miss it awaits the real fetch, stores the
7 * tool's record and returns what came back.
8 *
9 * Only the tool's own record (`result`) is cached, never core's `ref`: that
10 * number names the messages core produced for one specific call and must not
11 * be replayed on another.
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 * ttlSeconds: number how long an entry stays fresh (default 900)
18 * maxEntries: number cache size (default 200)
19 */
20import type { Register, ToolCallResult } from 'claude-code'
21
22type WebFetchRecord = Extract<ToolCallResult<'WebFetch'>, { result: unknown }>['result']
23
24interface Entry { result: WebFetchRecord; storedAt: number }
25
26// Module state lives for the session (a plugin's module is one worker).
27const cache = new Map<string, Entry>()
28
29export const register: Register = (on, options) => {
30 const ttlMs = (typeof options.ttlSeconds === 'number' ? options.ttlSeconds : 900) * 1000
31 const maxEntries = typeof options.maxEntries === 'number' ? options.maxEntries : 200
32
33 on('tool.call', { tool: 'WebFetch' }, async ($, e, next) => {
34 if (!e.url) return next(e)
35 // WebFetch reads a long page in pieces (`offset`, Claude Code 2.1.290+): each piece is its own entry,
36 // 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).
37 const offset = (e as { offset?: unknown }).offset
38 const key = `${e.url}\n${e.prompt}\n${typeof offset === 'number' ? offset : 0}`
39 const now = Date.now()
40
41 const hit = cache.get(key)
42 if (hit && now - hit.storedAt < ttlMs) {
43 const age = Math.round((now - hit.storedAt) / 1000)
44 $.ui.log(`[webfetch-cache] hit for ${e.url} (${age}s old)`)
45 // Nothing below this hook runs: no network call.
46 return {
47 result: hit.result,
48 context: [`webfetch-cache served this result from a ${age}s-old cache entry for the same URL, prompt and offset.`],
49 }
50 }
51
52 const outcome = await next(e)
53
54 // Do not cache a denial or an errored fetch.
55 if (outcome.deny === undefined && !outcome.isError) {
56 if (cache.size >= maxEntries) {
57 const oldest = cache.keys().next().value
58 if (oldest !== undefined) cache.delete(oldest)
59 }
60 cache.set(key, { result: outcome.result, storedAt: now })
61 }
62 return outcome
63 })
64}
65