SLOPSHOPPER

webfetch-cache

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…

newguard
A shopper browsing a rack in a slop shop
README

webfetch-cache

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.

Options

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

Install

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

Source 1 files
hooks/webfetch-cache.ts 65 lines
1/**
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