What happens when your database tool returns 8,000 rows in a single call, or your web fetch tool drops 200KB of HTML into the conversation? In my own multi-site auto-posting pipeline as an indie developer running six sites alongside an app business that has crossed 50 million cumulative downloads, this was the first wall I hit. A single tool call would consume more than 60,000 tokens, get called three or four times per loop, and saturate the context window before the agent could finish its job. Cost went up, but more painfully, output quality went down.
Tool result compression is a quiet but decisive part of agent design that compounds as you run agents longer. Anthropic's official docs do a great job explaining how to define tools, but the question of how to constrain the return side is essentially left to the implementer. Here I want to share the three-layer architecture I run in production for content generation, content auditing, and revenue analysis agents — with TypeScript samples you can adapt.
Three failure modes that bloated tool results trigger
The reasoning "the context window is bigger now, so raw payloads are fine" is wrong on both cost and quality grounds. Three things degrade at once:
- Direct token cost: Claude Sonnet 4.6 at $3 per million input tokens, multiplied by 60,000 tokens × 4 tool calls = $0.72 per request. A pipeline doing 200 such requests per day burns roughly $4,300 per month in pure overhead
- Instruction-following degradation: The longer the input, the weaker the relative weight of your system prompt and instructions. In my own runs, the rate of style-guide violations roughly doubles once cumulative tool results pass 30,000 tokens
- Cache efficiency collapse: With prompt caching enabled, tool results that change every call destroy your cache-hit rate, dragging it down in proportion to how many tools you call
So compression is not just a cost-optimization concern. It is a reliability-level architectural decision for the whole agent.
The three-layer compression model
The taxonomy I use in production looks like this. The top layer is safest (least information loss), the bottom layer is the most powerful (highest compression ratio). In practice I pick one layer per tool, and sometimes stack two.
Layer 1: Schema projection (return only the fields that matter)
The least lossy form of compression is to never include unnecessary fields. A Stripe Charge object has 50+ fields, but an agent doing spend analysis usually only needs id, amount, currency, created, and description.
async function searchCharges(query: SearchQuery) {
const charges = await stripe.charges.search({
query: query.text,
limit: 100,
});
// 50+ fields × 100 rows ~= 100k+ tokens → 5 fields × 100 rows ~= 10k tokens
return {
data: charges.data.map((c) => ({
id: c.id,
amount: c.amount,
currency: c.currency,
created: c.created,
description: c.description,
})),
has_more: charges.has_more,
next_cursor: charges.data[charges.data.length - 1]?.id,
};
}Projection alone usually buys 70–90% token reduction. When you write MCP servers that wrap external APIs, ask yourself which fields exist for human dashboard consumption vs. which fields the agent actually uses to decide. That distinction lands you in this layer naturally.
Layer 2: Summarization (have Haiku digest the payload first)
Some tools return free-form text that schema projection cannot squeeze. Web search, file reads, log retrieval — all of these. The pattern that works best for me is a cascade where Haiku compresses the payload before it ever reaches the Sonnet/Opus loop.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
async function fetchAndSummarize(url: string, focus: string) {
const html = await fetch(url).then((r) => r.text());
const text = stripHtml(html).slice(0, 50_000); // about 25,000 tokens
// 5,000-token input → 400-token summary
const summary = await client.messages.create({
model: "claude-haiku-4-5-20251001",
max_tokens: 400,
system: `Summarize the following web page. Focus angle: ${focus}.
Facts only. No speculation. Keep all important numbers verbatim.`,
messages: [{ role: "user", content: text }],
});
return {
url,
focus,
summary: summary.content[0].type === "text" ? summary.content[0].text : "",
original_length: text.length,
};
}The critical detail is passing the focus as a tool parameter. When Claude itself declares what it wants to know at the moment of calling, Haiku produces summaries aligned with the caller's intent, which dramatically reduces follow-up turns.
This mirrors a pattern Dolice (my indie app studio operating since 2014) has used on the ad-monetization side: a cheap layer screens, an expensive layer commits. The same economic logic applies to agent design — a Haiku cascade in front of Sonnet/Opus is the AdMob mediation of LLM orchestration.
Layer 3: Reference handles (return only an ID, not the data)
When the agent doesn't need to read the payload, only act on it, the strongest move is to make the tool return "saved, here is the handle" and let Claude drill down via separate tools.
const blobStore = new Map<string, unknown>();
async function executeQuery(sql: string) {
const rows = await db.query(sql);
const handle = `query-${crypto.randomUUID()}`;
blobStore.set(handle, rows);
// Don't return thousands of rows; return the handle plus a tiny preview
return {
handle,
row_count: rows.length,
sample: rows.slice(0, 3),
columns: rows[0] ? Object.keys(rows[0]) : [],
note: "use `query_result_aggregate` or `query_result_filter` with the handle to drill down",
};
}
async function queryResultAggregate(handle: string, groupBy: string, agg: string) {
const rows = (blobStore.get(handle) ?? []) as Record<string, unknown>[];
return aggregate(rows, groupBy, agg);
}The reference-handle pattern positions Claude as an analyst issuing aggregation directives rather than someone scrolling the raw data. In production I back blobStore with Cloudflare KV or Durable Objects with a one-hour TTL.