Skip to content

Instantly share code, notes, and snippets.

@mattpocock
Created July 7, 2026 11:51
Show Gist options
  • Select an option

  • Save mattpocock/5b3d76ea21f5f698aefded47a9cea3b1 to your computer and use it in GitHub Desktop.

Select an option

Save mattpocock/5b3d76ea21f5f698aefded47a9cea3b1 to your computer and use it in GitHub Desktop.
agent-proxy — a zero-dep proxy that shows the bloat in Claude Code's requests (ranked tool table + full readable Markdown of every request)

agent-proxy — see the bloat in Claude Code's requests

A zero-dependency logging proxy that sits between Claude Code and the Anthropic API. It forwards every request untouched, streams the reply straight back (so the CLI is unaffected), and writes a readable Markdown document for each request — led by a ranked table of what is eating your context.

Run it

node proxy.mjs

Then point Claude Code at it in another terminal:

ANTHROPIC_BASE_URL=http://localhost:8787 claude

Send one message. Each request is written to ./logs/<timestamp>_anthropic.md, and a ranked tool table prints to the terminal:

[agent-proxy] 69 tools · 154,946 tool bytes · 65,538 real input tokens
  Workflow      21229 B  ~5307 tok
  DesignSync     8978 B  ~2245 tok
  Monitor        7767 B  ~1942 tok
  …

Open the .md file to read the entire request — every tool schema, the system prompt, the message history — exactly as it was sent to the model. Requires Node 18+. Anthropic /v1/messages only.

/**
* agent-proxy — see what Claude Code actually sends the model.
*
* A zero-dependency logging proxy for Claude Code. It sits between the CLI and
* the Anthropic API, forwards every request untouched (auth header and all),
* streams the response straight back so the CLI is unaffected, and for each
* request writes a readable Markdown document — led by a ranked table of what
* is eating your context.
*
* Run: node proxy.mjs
* Point Claude Code at it:
* ANTHROPIC_BASE_URL=http://localhost:8787 claude
*
* Zero runtime dependencies — Node built-ins only. Requires Node 18+.
*/
import http from "node:http";
import https from "node:https";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const PORT = Number(process.env.PORT ?? 8787);
const UPSTREAM = "api.anthropic.com";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const LOG_DIR = path.join(HERE, "logs");
/** Rough token estimate for display. Real input tokens come from the response
* usage; this is only for ranking the request before the reply arrives. */
const estTokens = (bytes) => Math.round(bytes / 4);
/** count_tokens calls send content but get back only a number, never a reply.
* A single turn fires many as housekeeping — pure noise here, so skip them. */
const isTokenCount = (reqPath) => reqPath.includes("count_tokens");
const REDACT = new Set(["authorization", "x-api-key", "api-key"]);
/** Strip hop-by-hop and encoding headers so the captured response is readable,
* recompute content-length, and pass auth through untouched so the real request
* still authenticates. */
function forwardHeaders(headers, body) {
const out = { ...headers };
delete out["host"];
delete out["connection"];
delete out["accept-encoding"]; // force identity so we can read the stream
delete out["transfer-encoding"];
delete out["content-length"];
if (body.length > 0) out["content-length"] = String(body.length);
return out;
}
function baseName() {
const stamp = new Date().toISOString().replace(/:/g, "-").replace(".", "-").replace("Z", "");
return `${stamp}_anthropic`;
}
// ---------------------------------------------------------------------------
// The audit: rank what's in the request
// ---------------------------------------------------------------------------
/** Measure every removable region of the request and rank the tools by size.
* This is the whole point of the proxy — the numbers you cut against. */
function auditRequest(reqJson, realInputTokens) {
const tools = Array.isArray(reqJson?.tools) ? reqJson.tools : [];
const toolRows = tools
.map((t) => {
const bytes = Buffer.byteLength(JSON.stringify(t));
return { name: t?.name ?? "(unnamed)", bytes, tokens: estTokens(bytes) };
})
.sort((a, b) => b.bytes - a.bytes);
const toolsBytes = toolRows.reduce((n, r) => n + r.bytes, 0);
const systemBytes = reqJson?.system ? Buffer.byteLength(JSON.stringify(reqJson.system)) : 0;
const totalBytes = Buffer.byteLength(JSON.stringify(reqJson ?? {}));
return {
toolRows,
toolCount: toolRows.length,
toolsBytes,
systemBytes,
totalBytes,
realInputTokens,
};
}
/** The ranked table, as Markdown. The hero of the whole document. */
function renderAudit(a) {
const pct = (b) => (a.totalBytes ? ((b / a.totalBytes) * 100).toFixed(1) : "0.0");
const rows = a.toolRows
.map((r) => `| ${r.name} | ${r.bytes.toLocaleString()} | ~${r.tokens.toLocaleString()} | ${pct(r.bytes)}% |`)
.join("\n");
return [
"<audit>",
"",
a.realInputTokens != null
? `**${a.realInputTokens.toLocaleString()} input tokens** billed for this request (from the response usage).`
: "",
"",
`- **tools**: ${a.toolCount} definitions, ${a.toolsBytes.toLocaleString()} bytes (~${estTokens(a.toolsBytes).toLocaleString()} tokens)`,
`- **system prompt**: ${a.systemBytes.toLocaleString()} bytes (~${estTokens(a.systemBytes).toLocaleString()} tokens)`,
`- **total request**: ${a.totalBytes.toLocaleString()} bytes`,
"",
"**Tools, ranked by size — this is your cut list:**",
"",
"| tool | bytes | ~tokens | % of request |",
"| --- | --: | --: | --: |",
rows,
"",
"</audit>",
].join("\n");
}
/** The same ranking, compact, for the terminal — so you see the bloat live. */
function printAudit(a, base) {
const top = a.toolRows.slice(0, 12);
const w = Math.max(4, ...top.map((r) => r.name.length));
console.log(`\n[agent-proxy] ${a.toolCount} tools · ${a.toolsBytes.toLocaleString()} tool bytes` +
(a.realInputTokens != null ? ` · ${a.realInputTokens.toLocaleString()} real input tokens` : ""));
for (const r of top) {
console.log(` ${r.name.padEnd(w)} ${String(r.bytes).padStart(7)} B ~${r.tokens} tok`);
}
if (a.toolRows.length > top.length) console.log(` … ${a.toolRows.length - top.length} more`);
console.log(` logs/${base}.md\n`);
}
// ---------------------------------------------------------------------------
// Readable Markdown render (Anthropic /messages only)
// ---------------------------------------------------------------------------
const fenceJson = (v) => "```json\n" + JSON.stringify(v, null, 2) + "\n```";
const fence = (t, lang = "") => "```" + lang + "\n" + t + "\n```";
function blockText(b) {
if (typeof b === "string") return b;
if (b?.type === "text" && typeof b.text === "string") return b.text;
return "";
}
function renderSystem(system) {
if (typeof system === "string") return system;
if (Array.isArray(system)) {
return system
.map((b) => blockText(b) + (b?.cache_control ? "\n\n<!-- cache_control breakpoint -->" : ""))
.join("\n\n");
}
return fenceJson(system);
}
function renderTools(tools) {
const rendered = tools.map((t) => {
const lines = [`### ${t.name ?? "(unnamed tool)"}`, ""];
if (t.description) lines.push(t.description, "");
if (t.input_schema) lines.push(fenceJson(t.input_schema));
return lines.join("\n");
});
return ["<tools>", "", rendered.join("\n\n"), "", "</tools>"].join("\n");
}
function imagePlaceholder(b) {
const src = b.source ?? {};
const bytes = typeof src.data === "string" ? src.data.length : 0;
return `\`[image: ${src.media_type ?? "unknown"}, ${bytes} base64 chars — full data in .request.txt]\``;
}
function renderContent(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return fenceJson(content);
return content
.map((b) => {
switch (b?.type) {
case "text":
return b.text ?? "";
case "tool_use":
return [`<tool-use name="${b.name}" id="${b.id ?? ""}">`, "", fenceJson(b.input ?? {}), "", "</tool-use>"].join("\n");
case "tool_result": {
const inner =
typeof b.content === "string"
? b.content
: Array.isArray(b.content)
? b.content.map((x) => (x?.type === "image" ? imagePlaceholder(x) : blockText(x) || fenceJson(x))).join("\n\n")
: fenceJson(b.content);
return [`<tool-result tool-use-id="${b.tool_use_id ?? ""}" is-error="${!!b.is_error}">`, "", inner, "", "</tool-result>"].join("\n");
}
case "image":
return imagePlaceholder(b);
case "thinking":
return ["<thinking>", "", b.thinking ?? "", "", "</thinking>"].join("\n");
default:
return fenceJson(b);
}
})
.join("\n\n");
}
function renderMessages(messages) {
if (!Array.isArray(messages)) return "<messages></messages>";
const rendered = messages.map((m, i) =>
[`<message index="${i + 1}" role="${m.role ?? "unknown"}">`, "", renderContent(m.content), "", "</message>"].join("\n")
);
return ["<messages>", "", rendered.join("\n\n"), "", "</messages>"].join("\n");
}
/** Reassemble the streamed SSE response so we can read the reply — and pull the
* real input-token count out of the usage events. */
function decodeResponse(raw) {
const events = [];
for (const line of raw.split(/\r?\n/)) {
const m = line.match(/^data:\s?(.*)$/);
if (!m || m[1] === "[DONE]" || m[1].trim() === "") continue;
try { events.push(JSON.parse(m[1])); } catch { /* skip */ }
}
const blocks = {};
let stopReason, usage;
for (const ev of events) {
if (ev.type === "content_block_start") blocks[ev.index] = { type: ev.content_block?.type ?? "text", text: "", name: ev.content_block?.name, id: ev.content_block?.id };
else if (ev.type === "content_block_delta" && blocks[ev.index]) {
const d = ev.delta ?? {};
blocks[ev.index].text += d.text ?? d.partial_json ?? d.thinking ?? "";
} else if (ev.type === "message_start" && ev.message?.usage) usage = { ...ev.message.usage, ...(usage ?? {}) };
else if (ev.type === "message_delta") {
if (ev.delta?.stop_reason) stopReason = ev.delta.stop_reason;
if (ev.usage) usage = { ...(usage ?? {}), ...ev.usage };
}
}
const parts = [];
if (stopReason) parts.push(`- **stop reason**: ${stopReason}`);
if (usage) parts.push(`- **usage**: ${JSON.stringify(usage)}`, "");
for (const i of Object.keys(blocks).map(Number).sort((a, b) => a - b)) {
const b = blocks[i];
if (b.type === "text") parts.push(["<assistant-text>", "", b.text, "", "</assistant-text>"].join("\n"));
else if (b.type === "thinking") parts.push(["<thinking>", "", b.text, "", "</thinking>"].join("\n"));
else if (b.type === "tool_use") parts.push([`<tool-use name="${b.name}" id="${b.id ?? ""}">`, "", fence(b.text || "{}", "json"), "", "</tool-use>"].join("\n"));
}
const inputTokens = usage
? (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0)
: null;
return { markdown: parts.length ? parts.join("\n\n") : fence(raw), inputTokens };
}
function renderMarkdown(c, audit, responseMd) {
const headers = Object.entries(c.headers).map(([k, v]) =>
`${k}: ${REDACT.has(k.toLowerCase()) ? "[REDACTED]" : Array.isArray(v) ? v.join(", ") : v ?? ""}`
);
const req = c.reqJson;
const parts = [
["<meta>", "", `- **timestamp**: ${c.timestamp}`, `- **model**: ${req?.model ?? "unknown"}`, `- **endpoint**: ${c.method} ${c.path}`, `- **upstream status**: ${c.statusCode}`, "", "</meta>"].join("\n"),
renderAudit(audit),
["<headers>", "", "```", ...headers, "```", "", "</headers>"].join("\n"),
];
if (req?.system != null) parts.push(["<system-prompt>", "", renderSystem(req.system), "", "</system-prompt>"].join("\n"));
if (Array.isArray(req?.tools) && req.tools.length) parts.push(renderTools(req.tools));
parts.push(renderMessages(req?.messages));
parts.push("<response>\n\n" + responseMd + "\n\n</response>");
return parts.join("\n\n") + "\n";
}
// ---------------------------------------------------------------------------
// Server
// ---------------------------------------------------------------------------
function handle(req, res) {
const reqPath = req.url ?? "/";
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const body = Buffer.concat(chunks);
const timestamp = new Date().toISOString();
const base = baseName();
const upstream = https.request(
{ hostname: UPSTREAM, port: 443, path: reqPath, method: req.method, headers: forwardHeaders(req.headers, body) },
(up) => {
res.writeHead(up.statusCode ?? 502, up.headers);
const respChunks = [];
up.on("data", (c) => { respChunks.push(c); res.write(c); });
up.on("end", () => {
res.end();
if (isTokenCount(reqPath)) return;
try {
const reqJson = JSON.parse(body.toString("utf8"));
const { markdown, inputTokens } = decodeResponse(Buffer.concat(respChunks).toString("utf8"));
const audit = auditRequest(reqJson, inputTokens);
fs.mkdirSync(LOG_DIR, { recursive: true });
fs.writeFileSync(path.join(LOG_DIR, `${base}.request.txt`), body.toString("utf8"));
fs.writeFileSync(path.join(LOG_DIR, `${base}.md`), renderMarkdown({ reqJson, timestamp, method: req.method ?? "POST", path: reqPath, statusCode: up.statusCode ?? 0, headers: req.headers }, audit, markdown));
printAudit(audit, base);
} catch (err) {
console.error(`[agent-proxy] could not render (non-JSON body?): ${err.message}`);
}
});
}
);
upstream.on("error", (err) => {
console.error(`[agent-proxy] upstream error: ${err.message}`);
if (!res.headersSent) res.writeHead(502, { "content-type": "application/json" });
res.end(JSON.stringify({ error: `agent-proxy upstream error: ${err.message}` }));
});
if (body.length > 0) upstream.write(body);
upstream.end();
});
}
http.createServer(handle).listen(PORT, () => {
console.log(`[agent-proxy] listening on http://localhost:${PORT}`);
console.log(`[agent-proxy] point Claude Code at it: ANTHROPIC_BASE_URL=http://localhost:${PORT} claude`);
});
@r1cc4rd0m4zz4

r1cc4rd0m4zz4 commented Jul 7, 2026

Copy link
Copy Markdown

Hey Matt, great tool! The ranked table alone saves so much guesswork on context optimization.

I made a small fork adding a UPSTREAM_URL env var so you can point the proxy at any Anthropic-compatible endpoint (local dev servers, different ports, alternative providers). The original hardcodes https://api.anthropic.com, but with the rise of compatible backends a configurable upstream seemed useful.

Fork → https://gist.github.com/r1cc4rd0m4zz4/42c1a6a81874a5e59292d76192bfa1d2

Thanks for sharing this!

@rip222

rip222 commented Jul 8, 2026

Copy link
Copy Markdown

I get this in claude code:

There's an issue with the selected model (claude-opus-4-8[1m]). It may not exist or you may not have access to it. Run /model to pick a different model.

@marcinTichoniuk

Copy link
Copy Markdown

Without the proxy, my context shows 14.5k tokens; with the proxy, it shows 68k. Which is correct? Aren't most tools deferred anyway?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment