The proxy used to expose ~20 behavior toggles (--no-compress, --no-tools, --no-schemas, --no-reminders, --no-tool-results, --history, --no-history, --history-keep-tail N, --history-min-prefix N, --min-chars N, --min-reminder-chars N, --min-tool-result-chars N, --cols N, --multi-col N, --no-track, --events-file PATH, --port N, --upstream URL) plus matching env vars. Each one was a dead branch in the common case and a configuration knob nobody had a principled reason to pick a value for. Replaced with: exactly one way to run the proxy. Every compression mode on, every tuning parameter at its measured-best value, history compression always active (was opt-in dead code). The only adjustable surface is deployment concerns — where to listen, what to forward, where to log — env-var only: PORT default 47821 ANTHROPIC_UPSTREAM default https://api.anthropic.com PIXELPIPE_LOG default ~/.pixelpipe/events.jsonl CLI accepts only --help and --version. Anything else exits with non-zero and a one-line error. Notable behavior change: - DEFAULTS.compressHistory flipped false → true. Variant-C history- image compression now runs on every request (was opt-in via --history / COMPRESS_HISTORY=1). The static-slab cache_control placement is on the system image, not on the history-replacement chain, so the published cache-topology risk does not apply. Touched: - src/node.ts: CliOpts → RuntimeConfig (3 fields). parseCli is now ~25 lines. printHelp reduced to env-var-only doc. - src/core/transform.ts: DEFAULTS.compressHistory = true. Comment updated. - scripts/restart.sh: drops PROXY_ARGS array entirely. Port read from PORT= env var. Unknown args rejected. - tests/restart.test.sh: replaces flag-passthrough test with a reject-unknown-args test. 4/4 pass. - tests/history.test.ts: renames the "compressHistory:false (default)" test to reflect the new always-on default; asserts the input-not- mutated invariant remains. - README.md: configuration table reduced to 3 env vars; restart examples drop CLI flag passthrough. Tests: 277/277. Typecheck + build clean. Restart script tests: 4/4.
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 GNU Unifont glyph atlas (covers ~35k
BMP codepoints by default — Latin, Cyrillic, Greek, CJK, Hiragana,
Katakana, Hangul, Hebrew, Arabic, math symbols, box drawing, decorative
symbols), 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
After editing code, restart in one step:
pnpm run restart # graceful SIGTERM → rebuild → start
pnpm run restart -- --no-build # skip rebuild (dist/ is fresh)
PORT=47822 pnpm run restart # override listen port via env
pnpm run restart does, in order:
- Lists every running pixelpipe PID (via
pgrep) and SIGTERMs them all. Orphans from prior crashed sessions are cleaned up too. - Waits up to 5s for graceful exit (the SIGTERM handler flushes the JSONL tracker). Escalates to SIGKILL only if anything's still alive.
- Runs
pnpm run build. Build failures abort the restart — the script refuses to start a stale binary. Pass--no-buildto skip when you knowdist/is fresh. - Checks the target port is free. If it isn't, names the holding process and refuses to start (cheaper than a crashed Node stacktrace).
execsnode bin/cli.jsin the foreground so Ctrl-C reaches Node. The proxy takes no behavioral flags — env vars only (see Configuration).
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
The proxy runs with a single codepath. Every compression mode is on, every break-even threshold is at its measured-best value, and tuning parameters are not user-adjustable. The only configurable surface is where to listen, what to proxy, and where to log — env-var only, no CLI flags.
| env var | default | meaning |
|---|---|---|
PORT |
47821 |
Node only — listen port |
ANTHROPIC_UPSTREAM |
https://api.anthropic.com |
upstream API base |
PIXELPIPE_LOG |
~/.pixelpipe/events.jsonl |
persistent event log |
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) sparse Unicode atlas, base64-inlined
│ ├── 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: OTF → atlas.ts (uses @napi-rs/canvas)
└── build.mjs esbuild bundler for Node target
assets/
├── Unifont-16.0.04.otf primary font (~35k BMP codepoints w/ full-bmp profile)
├── UNIFONT_LICENSE.txt OFL + GPL-with-font-exception
└── JetBrainsMono-Regular.ttf legacy / ASCII-only fallback (kept on disk)
The atlas is generated at build time from Unifont-16.0.04.otf,
base64-inlined into a .ts file with sparse codepoint + offset tables
(binary-packed), 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 (after swapping fonts, sizes, or codepoint profile):
pnpm run build:atlas # default: full-bmp (~35k cp, all BMP Unifont covers)
ATLAS_PROFILE=practical pnpm run build:atlas # drops Hangul (~24k cp; for Workers free-tier)
FONT_PX=12 pnpm run build:atlas # nondefault size; verify cells
Limitations
- The bundled GNU Unifont at 10px (cell 5×11 px Latin, 10×11 CJK) is
Anthropic-OCR-clean for ~35k BMP codepoints by default (
full-bmpprofile): Latin, Cyrillic, Greek, CJK Unified Ideographs, Hiragana, Katakana, Hangul, Hebrew, Arabic, math symbols, box-drawing, arrows, Dingbats, Letterlike Symbols, Enclosed Alphanumerics, etc. Drops for codepoints outside the profile (e.g. emoji 😀 — supplementary plane) get counted inevents.jsonl#dropped_chars(with the top-20 broken out asdropped_codepoints_top) so you can spot patterns. For Workers free-tier deployments under the 1 MB compressed-bundle cap, switch toATLAS_PROFILE=practical pnpm run build:atlas(~24k cp; drops Hangul). Right-to-left scripts render left-to-right in source order (no bidi shaping); Devanagari / Thai / similar complex shaping is also unsupported. - Compression sets a 5-minute prompt-cache TTL. Adding
cache_control: ephemeralcauses warm-cache rotation, not eviction. - A 5KB break-even point: if input is
< MIN_COMPRESS_CHARSchars we skip compression entirely (overhead would exceed savings). - Per-machine font: regenerate the atlas if you swap fonts. The
generated
src/core/atlas.tsis checked in so consumers don't need@napi-rs/canvasto 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
pnpm run build:atlas # regenerate src/core/atlas.ts from OTF
npm run build # build dist/node.js
npm run deploy:worker # wrangler deploy
License
MIT.