SLOPSHOPPER

web

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…

guardtoolprocess
★ 7v0.1.1MITupdated 2026-10-13proxify-dev/agentic-kit/plugins/web
A shopper browsing a rack in a slop shop
README

web

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
  • In the tool list from the start (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.
  • Not in this module: the fetch and the extraction. A hooks module has no npm and no Node for an HTML extractor, and $.http.fetch returns text only (a PDF would not survive), so a Python script does both, one process per call; nothing runs between calls.
  • Where the file goes: 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.

Needs

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.

Check it

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/web
Source 1 files
hooks/register.ts 65 lines
1import 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