WebFetchRaw, a tool beside WebFetch: it fetches a URL and saves the page's own content to a file, with no model in between, and returns one line naming the…

WebFetchRaw: a web page's own content in a file, where WebFetch gives a small model's answer about it.
Claude Code's WebFetch converts the page to markdown, hands it to a small model with your prompt and returns that model's reply. Asking it for the page verbatim does not get the page. On 2026-10-13, asked for example.com's text "verbatim, character for character", it answered:
I can't reproduce the full page verbatim because quotes are limited to 125 characters, but here is the closest I can provide:
"Example Domain. This domain is for use in documentation examples without needing permission."
The page also says the domain isn't a service and shouldn't be used for testing or monitoring.
The page says "This is not a service; avoid relying on it for testing and monitoring purposes." WebFetchRaw writes the page itself to a file and returns one line:
ok: wrote 34038 bytes to /tmp/…/webfetch-raw/docs.python.org/20261012-221501-3_library_json.html-1a2b3c4d.md (markdown, text/html)
The page enters the context only when the agent Reads that file, whole or in parts.
the model calls mcp__web__WebFetchRaw { url, path?, output_format?, … }
└─ hooks/register.ts answers the call itself (a Claude Mod: no MCP server)
└─ $.process.run: uv runs scripts/webfetch_raw.py, the input as JSON on stdin
├─ an HTML page → trafilatura → markdown · text · cleaned html (main content, metadata on top)
├─ anything else (JSON, text, markdown, a PDF), or output_format "raw" → the body byte for byte
└─ stdout: the one ok: line → the tool's result · stderr: error: … → an error result
isDeferred: false), beside WebFetch, so the model reaches for it when it needs exact words, code, numbers or links. Asked for "the exact wording of the paragraph on example.com", a claude -p session picked it over WebFetch without being told.$.http.fetch returns text only (a PDF would not survive), so a Python script does both, one process per call; nothing runs between calls.path (absolute; folders are made), else <tmp>/webfetch-raw/<host>/<time>-<slug>-<hash>.<ext>.It replaces an earlier MCP server, webfetch-verbatim (tool WebFetchVerbatim). Changed on the way: no MCP server; raw is the body as sent (it was prefixed with a header line, and decoded as text); content that is not HTML is saved as sent, with its own extension; the byte count is bytes.
uv, and CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the env of your Claude Code settings (ak setup switches it on with this plugin; installed with /plugin install web@agentic-kit, set it yourself). The first call builds the plugin's environment (trafilatura, lxml): a few seconds, once. A call goes through Claude Code's permission check like any tool's; mcp__web__WebFetchRaw in permissions.allow lets it run without asking.
uv run pytest -q # in plugins/web: the script, against a local HTTP server
claude plugin test plugins/web # the mod: registration, the call, its errors
claude plugin validate plugins/webhooks/register.ts 65 lines1import type { Register } from 'claude-code'
2
3// The tool is declared here and served by scripts/webfetch_raw.py, run once per call. Not in this module:
4// it has no npm and no Node for an HTML extractor, and $.http.fetch hands back text only (a PDF would not
5// survive), so the fetch, the extraction (trafilatura) and the write are the script's.
6const NAME = 'WebFetchRaw'
7
8const DESCRIPTION = `Fetch a URL and save the page's own content to a file. No model reads it on the way.
9
10WebFetch passes the page to a small model and returns that model's answer about it, which is a paraphrase. This tool writes the page itself to a file and returns one line naming the file; Read the file after, whole or in parts (offset/limit). Use it when you need the exact words, code, numbers or links on a page. Use WebFetch to ask a question about a page.
11
12An HTML page is extracted by trafilatura (the main content, without navigation and footer) into output_format. Any other content (JSON, plain text, markdown, a PDF) is written byte for byte, and so is an HTML page with output_format "raw".`
13
14const INPUT_SCHEMA = {
15 type: 'object',
16 properties: {
17 url: { type: 'string', description: 'The http:// or https:// URL to fetch.' },
18 path: {
19 type: 'string',
20 description: 'Absolute path of the file to write; missing folders are created. Left out, the file is written to <tmp>/webfetch-raw/<host>/<time>-<slug>-<hash>.<ext>.',
21 },
22 output_format: {
23 type: 'string',
24 enum: ['markdown', 'text', 'html', 'raw'],
25 default: 'markdown',
26 description: 'For an HTML page: markdown, text, html (the main content as cleaned HTML), or raw (the body exactly as the server sent it).',
27 },
28 include_links: { type: 'boolean', default: true, description: 'Keep link targets in the extracted content.' },
29 include_images: { type: 'boolean', default: false, description: 'Keep image references in the extracted content.' },
30 include_tables: { type: 'boolean', default: true, description: 'Keep tables in the extracted content.' },
31 include_comments: { type: 'boolean', default: false, description: "Keep the page's comment section in the extracted content." },
32 timeout: { type: 'integer', minimum: 1, maximum: 300, default: 30, description: 'Seconds to wait for the server.' },
33 overwrite: { type: 'boolean', default: true, description: 'false refuses to replace a file already at path.' },
34 },
35 required: ['url'],
36 additionalProperties: false,
37}
38
39export const register: Register = on => {
40 on('session.start', async ($, e, next) => {
41 const started = await next(e)
42 // isDeferred false: the schema sits in the tool list beside WebFetch, so the model reaches for it
43 await $.tool.register({ name: NAME, description: DESCRIPTION, inputSchema: INPUT_SCHEMA, isDeferred: false })
44 return started
45 })
46
47 // The hook IS the tool: no next(e), it answers the call itself.
48 on('tool.call', { tool: 'mcp__web__WebFetchRaw' }, async ($, e) => {
49 const { tool, tool_use_id, consent, agentId, requestMeta, ...input } = e as Record<string, unknown>
50 const root = $.plugin.root
51 const seconds = typeof input.timeout === 'number' ? input.timeout : 30
52 try {
53 const ran = await $.process.run(
54 ['uv', 'run', '--quiet', '--frozen', '--no-dev', '--project', root, 'python', `${root}/scripts/webfetch_raw.py`],
55 // the server's wait, plus room for uv to build the environment on a first call
56 { stdin: JSON.stringify(input), timeoutMs: Math.min((seconds + 90) * 1000, 600_000) },
57 )
58 if (ran.exitCode === 0) return { result: ran.stdout.trim() }
59 return { deny: ran.stderr.trim() || `webfetch_raw.py exited ${ran.exitCode}` }
60 } catch (err) {
61 return { deny: `error: webfetch_raw.py did not run: ${err instanceof Error ? err.message : String(err)}` }
62 }
63 }).catch(() => ({ deny: `error: the ${NAME} hook failed (claude --debug says why)` }))
64}
65