Read tool: `pages=` reroutes PDF reading through pdftoppm (hard-fails without poppler, 100-DPI JPEGs with it) while plain Read is fully native — and the tool description steers models onto the failing path

Status Open
Reported on v2.1.222
Maintainer reply None cached
Activity 0 comments · opened Aug 7, 2026

Environment

  • Claude Code 2.1.222 (failure observed) / 2.1.224 (behavior re-verified from the installed binary)
  • macOS (Darwin 25.5.0, Apple Silicon), native binary install
  • poppler not installed (pdftoppm, pdftotext, pdfinfo all absent) — i.e. the default state of most machines, per #23704

Summary

The Read tool has two completely different code paths for PDFs, and the tool description steers the model onto the wrong one:

| Call | Mechanism | Result on a popplerless machine |
|------|-----------|--------------------------------|
| Read(pdf) — no pages | base64 whole file → API native PDF reading (text + full-resolution page render) | Works, measured on 2-, 14-, and 105-page PDFs |
| Read(pdf, pages="…") | spawns pdftoppm -jpeg -r 100 locally, sends the JPEGs | Always fails: pdftoppm is not installed… |

So on any machine without poppler, pages is never required and always fatal — there is no document size at which the failing path becomes necessary. Yet the tool description says:

"Reads PDFs via the pages parameter (e.g. "1-5", max 20 pages/request; required for PDFs over 10 pages)"

Both clauses are wrong for the working path: reading is not "via" pages, and a plain no-pages Read returned all pages of a 105-page PDF (the API caps are far higher than 10). The ">10 pages" gate apparently can't even be enforced locally without poppler, since the page-count pre-check shells out to pdfinfo.

Even with poppler installed, the pages path is a downgrade: it replaces the API's native text+render ingestion with local 100-DPI JPEGs.

What actually happens in practice (the cascade)

Observed with claude-opus-5 on 2.1.222, and reproduced deterministically in a sandboxed replay harness (fresh temp cwd, no user config, -p mode, 3/3 runs):

  1. The model, following the tool description, calls Read(pdf, pages="1-3") on a 2-page PDF.
  2. Gets pdftoppm is not installed… — which fires at the availability probe, before the PDF is even opened, so the error says nothing about the file.
  3. Never retries the plain form. Instead it falls back to shell extraction (strings, hand-rolled zlib/ASCII85 content-stream decoding, pypdf), which is slower, error-prone (one run produced a false "string not found" on the exact provenance check the user had asked for), and text-only.
  4. Tells the user it cannot see the page and recommends brew install poppler — for a capability it already had via one argument-free call. In our replays, 3/3 baseline runs volunteered some form of "I couldn't render the page — installing poppler would let me confirm visually."

The tool description doesn't just permit this failure — it causes it: a model that trusts "reads PDFs via the pages parameter" reasonably concludes after the error that PDF reading is unavailable on the machine.

Verification detail

The mechanism claims above are read from the installed binary's embedded source (strings of ~/.local/share/claude/versions/2.1.224), not inferred from the error message:

  • Plain path: stat → size cap → %PDF- header check → base64 whole file → {type:"pdf"} document block. No external tool.
  • pages path: pdftoppm -v availability probe (absent → the exact error above, PDF never opened) → pdftoppm -jpeg -r 100 [-f N] [-l M] <file> <tmpdir>/page → send JPEGs. Page counting for limits uses pdfinfo (returns null when absent).

Suggested fixes (any subset helps)

  1. Fallback instead of hard error: when pdftoppm is unavailable (or the render fails), serve the request via the native path — it returns a superset of what the pages call would have produced. A note in the tool result ("page filter unavailable, returning whole document") preserves transparency.
  2. Fix the tool description: present pages as an optional filter, not the PDF mechanism; drop or correct "required for PDFs over 10 pages" (false on the native path, and unenforceable without poppler anyway).
  3. Prefer native slicing: if page filtering is worth keeping, extracting the requested pages into a temporary PDF (pure-JS, no external binary) and sending that through the native path would keep full resolution and drop the poppler dependency entirely.
  4. At minimum, document poppler as an optional dependency and what exactly requires it (per #23704, still open since February).

Related open issues

  • #23704 — poppler dependency undocumented, usually absent, not detected after install
  • #42248 / #42330 — macOS desktop app strips PATH, so even installed poppler isn't found
  • #73714 / #75421 — same on Windows (PATHEXT / .exe resolution)
  • #23699 — Read tool errors silently swallowed in TUI

These are all downstream of the same design: reading a PDF should not depend on a local rasteriser when the client already has a fully native path.

Repro

On a machine without poppler:

# 1. Any PDF, any size:
#    Read(file.pdf)              -> works: text + full-res page images
#    Read(file.pdf, pages="1")   -> "pdftoppm is not installed..."
# 2. Ask a session a question that names a specific page of a PDF
#    ("what does page 2 of <doc> say about X?") and watch it take the
#    pages= path per the tool description, error, and fall back to shell
#    extraction instead of retrying the plain form.

View original on GitHub ↗