teamchong 40037568e2 transform: compress <system-reminder> blocks in the first user message
Claude Code re-injects the same long <system-reminder> blocks (CLAUDE.md
hints, task-tools reminders, the dynamic skills list, etc.) into the
FIRST user message on every turn. They're text, so they're not part of
the system+tools image we already cache — they hit the wire fresh every
turn. This rewrite renders any reminder block ≥ minReminderChars (1000
by default, matching legacy/python/proxy.py) to PNG images placed inside
the user message content array, just like the static slab.

Why: the system+tools image is the only cache_control breakpoint we're
allowed (Anthropic caps at 4 and Claude Code uses the others). Per-block
reminder images therefore carry NO cache_control — they're per-turn
content that the model OCRs without prefix-cache benefit. The win is
token cost, not cache: reminders are 65-73% cheaper as PNG than as text,
and a typical turn has 1-3 of them.

Layout inside the first user message after compression:
  [intro text]                       ← static (helps OCR framing)
  [image block(s)]                   ← static; LAST has cache_control
                                        ↑ cache breakpoint (the only one)
  [End of rendered context.]         ← static text closer for the image
  [processed existing content]       ← per-turn (incl. reminder images)

Wire-up:
  - src/core/transform.ts: new TransformOptions.compressReminders (default
    true) + minReminderChars (default 1000). Two new helpers,
    textToImageBlocks() (wraps renderTextToPngs + makeImageBlock with
    no cache_control) and approxBlockBytes() (b64 → byte-count for the
    imageBytes telemetry, no second base64 round-trip). The placement
    ='user' branch now walks `existing` and rewrites long reminders.
  - src/core/tracker.ts: TrackEvent.reminder_imgs flows from
    TransformInfo.reminderImgs through toTrackEvent so `pixelpipe stats`
    and the dashboard can attribute compression contributions.
  - src/worker.ts: COMPRESS_REMINDERS + MIN_REMINDER_CHARS env vars.
  - src/node.ts: matching CliOpts, parseCli cases (--no-reminders,
    --min-reminder-chars), help text, env-var summary, and `rem+N` in
    the per-request console line when reminder_imgs > 0.

Tests:
  - "compresses long <system-reminder> blocks in the first user message"
    confirms info.reminderImgs ≥ 1, the reminder text is gone from
    user-content text blocks, the user's actual prompt survives, and the
    new reminder image blocks carry NO cache_control.
  - "leaves short <system-reminder> blocks alone (below minReminderChars)"
    confirms info.reminderImgs is 0 and the reminder text passes through
    as text.
2026-05-18 17:29:56 -04:00
2026-05-18 11:52:02 -04:00

pixelpipe

A token-saving proxy for Claude Code that renders the system prompt + tool definitions as images, so Claude OCRs them instead of paying for them as text. 65-73% input-token savings on Opus 4.7, 100% reasoning quality preserved, identical fixed text every turn for a clean prompt-cache.

Runs on Node 18+ and Cloudflare Workers from the same source.


How it works

                                  ┌─ original ────────────────────┐
                                  │ ~68K input tok                │
Claude Code  ──►  pixelpipe  ──►  │  (system + tools as text)     │  ──►  Anthropic
                       │          └───────────────────────────────┘
                       └──────►   ┌─ via proxy ───────────────────┐
                                  │ ~3.5K input tok               │
                                  │  (system + tools as PNG +     │
                                  │   prompt-cache breakpoint)    │
                                  └───────────────────────────────┘
                                          ↓ Anthropic vision OCR
                                          100% reasoning quality retained

The proxy intercepts POST /v1/messages, pulls the system prompt + tool documentation out of the JSON body, renders it into one or more grayscale PNGs using a build-time-generated JetBrains Mono glyph atlas, and substitutes those PNGs back in as image content blocks with an ephemeral cache_control breakpoint.

Token math (Opus 4.7, real Claude Code workflow):

metric original via proxy savings
Cold input tokens ~68K ~3.5K 95%
Cache-warm input tokens ~7.5K ~3.5K 53%
Per-call median (mixed) - - 65-73%
Per-image OCR quality vs txt - - ~99.5%

Quick start (Node)

npm install
npm run build           # produces dist/node.js
node bin/cli.js         # listens on 127.0.0.1:47821 by default

Point Claude Code at it:

ANTHROPIC_BASE_URL=http://127.0.0.1:47821 \
  claude --exclude-dynamic-system-prompt-sections

That's it. Use Claude Code normally.

The --exclude-dynamic-system-prompt-sections flag suppresses the small per-turn variable section so the rendered image stays byte-identical across turns — that's what makes the prompt cache actually hit.


Quick start (Cloudflare Workers)

npx wrangler dev        # local dev on :8787
npx wrangler deploy     # ship to *.workers.dev

Then in Claude Code:

ANTHROPIC_BASE_URL=https://pixelpipe.<your-account>.workers.dev \
  claude --exclude-dynamic-system-prompt-sections

You can attach a custom hostname and route in wrangler.toml.


Configuration

Both runtimes read the same options — Node from CLI flags or env, Worker from wrangler.toml [vars].

flag / var default meaning
--port PORT 47821 Node only — listen port
--upstream ANTHROPIC_UPSTREAM https://api.anthropic.com where to forward
--no-compress COMPRESS=0 on master switch
--no-tools COMPRESS_TOOLS=0 on fold tool docs into the image
--no-schemas COMPRESS_SCHEMAS=0 on include input_schema JSON in the image
--min-chars MIN_COMPRESS_CHARS 2000 skip compression below this many chars
--placement PLACEMENT system system or user — where image lands
--cols COLS 100 soft-wrap column count

In Workers, set the optional upstream API key with:

npx wrangler secret put ANTHROPIC_API_KEY

If unset, the proxy forwards whatever x-api-key the client sent.


Architecture

src/
├── core/              100% runtime-agnostic (Web Standard APIs only)
│   ├── atlas.ts         (generated) base64-inlined glyph bitmap
│   ├── png.ts           minimal grayscale PNG encoder
│   ├── render.ts        text → PNG bytes
│   ├── transform.ts     request body rewriter
│   ├── proxy.ts         the fetch handler
│   └── types.ts         Anthropic API types
├── node.ts            node:http adapter + CLI
└── worker.ts          export default { fetch }

scripts/
├── gen-atlas.ts       build-time: TTF → atlas.ts (uses @napi-rs/canvas)
└── build.mjs          esbuild bundler for Node target

assets/
└── JetBrainsMono-Regular.ttf

The atlas is generated at build time, base64-inlined into a .ts file, and shipped with the bundle. At runtime there are zero external files to read and zero non-Web-Standard imports — that's the only way this works in Workers without per-request asset fetches.

Regenerate the atlas (e.g., after swapping the font or font size):

FONT_PX=15 npm run build:atlas

Limitations

  • Sub-9pt full OCR. Menlo (Python proxy default) is in the verified floor; the bundled JetBrains Mono at 15px is comparable. Smaller sizes cause OCR errors.
  • Compression sets a 5-minute prompt-cache TTL. Adding cache_control: ephemeral causes warm-cache rotation, not eviction.
  • A 5KB break-even point: if input is < MIN_COMPRESS_CHARS chars we skip compression entirely (overhead would exceed savings).
  • Per-machine font: regenerate the atlas if you swap fonts. The generated src/core/atlas.ts is checked in so consumers don't need @napi-rs/canvas to install.
  • Workers CPU limit: this is fine for free-tier (10ms CPU) on small prompts; large prompts (>30K chars) may need the paid tier.

Development

npm install
npm run dev:node              # tsx watch on src/node.ts
npm run dev:worker            # wrangler dev
npm run test                  # vitest
npm run test:watch
npm run typecheck             # tsc --noEmit
npm run build:atlas           # regenerate src/core/atlas.ts from TTF
npm run build                 # build dist/node.js
npm run deploy:worker         # wrangler deploy

Legacy

The original Python proxy (the reference implementation we ported from) lives in legacy/python/. It's kept around as a parity oracle and will be deleted once TS reaches end-to-end byte-output parity.

License

MIT.

S
Description
cut Fable 5 token usage by rendering text context as images
Readme MIT 24 MiB
Languages
TypeScript 86.7%
JavaScript 9.5%
Shell 2.1%
Python 1.7%