●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Hierarchical Chat History Summarization with Claude API: A 3-Tier Design That Cut Tokens by 70%
Working TypeScript design for compressing long in-app chat histories into three tiers — recent turns kept verbatim, mid-range episodes summarized with Haiku 4.5, and long-range memory distilled to JSON by Sonnet 4.6. Includes seven weeks of production data showing input tokens down 70% and monthly API cost down from $480 to $145.
In mid-April 2026, the in-app AI chat I run inside one of my wallpaper apps started silently doubling its per-session API cost. I did not notice it from the monthly graph. I noticed it from a review that said "the assistant feels slower these days," which finally pushed me to open the dashboard. The cause was simple: heavy users had long histories, and every single turn was sending the whole transcript back to the model. Sessions with 40 turns easily passed 18,000 input_tokens.
I have been a solo iOS and Android developer since 2014, with around 50 million cumulative downloads across an AdMob-driven app catalog of wallpapers and relaxation apps. I have lived through plenty of server-side cost incidents over those years. The Claude API failure mode is different though: nothing is broken, users are not complaining about quality, but input_tokens climbs in a straight line because history is linear. I spent a few days rewriting the design around hierarchical compression rather than trimming, and over seven weeks of running totals the average input tokens dropped by 70%.
This article is the implementation note for that hierarchical summarizer. It splits the history into three tiers — Tier 1 keeps the recent turns verbatim, Tier 2 compresses mid-range episodes with Haiku 4.5, and Tier 3 distills long-range memory into a JSON object using Sonnet 4.6. Trimming alone could not reach the compression ratio and the contextual fidelity we needed. The code is adapted from the production version that runs on Cloudflare Workers in TypeScript.
What "Bloated History" Really Looks Like in an Indie App
Every chat-style integration that appends turns to the messages array eventually hits this wall. Short sessions are fine. The top 5% of my long-tail users were burning 38% of the monthly API budget, and because that app monetizes via AdMob rather than subscriptions, every long session was a net cost. That structural mismatch made it impossible to ignore.
Why Naive Trimming Broke User Trust
The first thing I tried was a fixed cap: keep only the last 10 turns. That works on cost, but it throws away context the user expects me to remember. Color preferences from earlier in the session, themes the user rejected, the project they are slowly building — these still need to be referenced 20 turns later. Cheap trimming creates the "the assistant keeps forgetting" feeling. I got that exact complaint multiple times in App Store reviews.
"Compressed Memory" Instead of "Forgetting"
Hierarchical summarization separates the recent flow from the long-range context. Keep recent turns raw, replace older ones with summaries, and reduce the oldest into an abstracted entity set. If trimming is forgetting, then hierarchical summarization is "compressed memory." This is the design philosophy I keep coming back to as an indie developer running multiple apps where I cannot afford either expensive sessions or shallow assistants.
The Three-Tier Architecture at a Glance
The production design partitions history into three tiers.
Tier
Content
Compression
Target tokens
Tier 1
Last 6 turns
Verbatim
~2,500
Tier 2
Mid-range episodes (summarized)
Haiku 4.5, ~200 tokens each
800–1,200 total
Tier 3
Long-range entities and intents
JSON via Sonnet 4.6
~600
Why Tier 1 Is Verbatim
Tier 1 preserves conversational continuity. Six turns is the sweet spot for my wallpaper app because users who say "redo your last suggestion" or "go back to the idea three turns ago" almost always refer to something inside that window. Fewer than six and the conversation breaks; more and token savings hit diminishing returns.
Why Haiku 4.5 Handles Tier 2
Tier 2 is mid-range memory. Once history grows past the Tier 1 budget, the overflow is sliced into six-turn episodes and Haiku 4.5 condenses each to roughly 200 tokens. Haiku is the right model for summarization-style inference: in my own scoring, Haiku-generated summaries hit about 88% of the subjective quality of Sonnet summaries while costing roughly one sixth per call.
Why Sonnet 4.6 Handles Tier 3
Tier 3 is long-range memory. After three episodes accumulate, I run them through Sonnet 4.6 to distill stable preferences, rejected ideas, the user's ongoing intent, and named anchors into a small JSON object. Distillation is harder than summarization, and Haiku occasionally drops important entities here, so the extra spend on Sonnet is worth it.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Full TypeScript implementation of a Tier 1–3 hierarchical summarizer with concrete threshold numbers
✦Cost breakdown of using Haiku 4.5 for episodic summaries and Sonnet 4.6 for distillation, with seven weeks of running totals
✦An async-first compression schedule on Cloudflare Queues so summarization never pushes user-visible latency up
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The hard part of Tier 1 was not the code; it was choosing the threshold. I originally fixed it at "last 10 turns" and discovered that for fast-paced users that was redundant, while for slow-paced users 10 turns was not enough. I now budget Tier 1 by tokens instead of turn count.
// src/history/tier1.tsimport type { Message } from "@anthropic-ai/sdk/resources/messages";export interface Tier1Result { recent: Message[]; overflow: Message[]; tokenEstimate: number;}const TIER1_TOKEN_BUDGET = 3_000;const TIER1_MIN_TURNS = 4;// Mixed JA/EN content: 1 token ~= 0.6 characters as a rough heuristic.function estimateTokens(message: Message): number { const content = typeof message.content === "string" ? message.content : message.content.map((c) => "text" in c ? c.text : "").join(""); return Math.ceil(content.length / 0.6);}export function partitionTier1(history: Message[]): Tier1Result { const recent: Message[] = []; let used = 0; for (let i = history.length - 1; i >= 0; i--) { const cost = estimateTokens(history[i]); if (used + cost > TIER1_TOKEN_BUDGET && recent.length >= TIER1_MIN_TURNS) { return { recent: recent.reverse(), overflow: history.slice(0, i + 1), tokenEstimate: used, }; } recent.push(history[i]); used += cost; } return { recent: recent.reverse(), overflow: [], tokenEstimate: used };}
The Minimum-Turns Guard
TIER1_MIN_TURNS guarantees that no matter how token-heavy the recent turns are, at least four turns are kept. My app exposes Continue and Refine buttons that explicitly reference the last assistant turn, and those are tapped more than 8,000 times a day. If Tier 1 ever cut them out, the reviews would not be kind.
Why a Cheap Token Estimator Is Fine
A precise count is available via messages.count_tokens, but calling it on every partitioning step adds cost and latency. The 0.6-characters-per-token heuristic is within 4% of the real count in my logs, which is precise enough for partitioning decisions even if it would be too rough for billing analysis.
Tier 2: Compressing Episodes to ~200 Tokens with Haiku
Tier 2 takes the overflow from Tier 1, slices it into six-turn episodes, and hands each chunk to Haiku 4.5. The prompt trick that mattered most was isolation: do not let the parent chat's system prompt leak into the summarizer, or the summary becomes "a description of the app" rather than a record of the actual conversation.
// src/history/tier2.tsimport Anthropic from "@anthropic-ai/sdk";import type { Message } from "@anthropic-ai/sdk/resources/messages";const anthropic = new Anthropic();const SUMMARIZE_SYSTEM = `You compress a chunk of a chat conversation.Output a single paragraph in the user's language, ~200 tokens, written in the third person.Preserve: explicit preferences, rejected ideas, decisions, in-progress requests.Drop: greetings, filler, repeated acknowledgements.Never invent facts; if uncertain, omit.`;export interface EpisodeSummary { range: [number, number]; summary: string; createdAt: number;}export async function summarizeEpisode( episode: Message[], baseIndex: number,): Promise<EpisodeSummary> { const transcript = episode .map((m) => `[${m.role}] ${flattenContent(m)}`) .join("\n"); const res = await anthropic.messages.create({ model: "claude-haiku-4-5-20251001", max_tokens: 320, system: SUMMARIZE_SYSTEM, messages: [{ role: "user", content: transcript }], }); const text = res.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text).join("\n").trim(); return { range: [baseIndex, baseIndex + episode.length - 1], summary: text, createdAt: Date.now(), };}function flattenContent(m: Message): string { if (typeof m.content === "string") return m.content; return m.content .map((c) => "text" in c ? c.text : "[non-text content]") .join(" ");}
Fixed-Size Episode Boundaries Work Surprisingly Well
Semantic boundaries — slicing where the conversation actually shifts topic — would in theory yield better summaries. In practice, a fixed six-turn window has been fine. The cost of an extra Claude call to find boundaries always outweighed the marginal quality gain, and the summarizer is good enough at preserving meaning across arbitrary cuts.
Always Preserve Speaker Attribution
A subtle detail that mattered: keep [user] and [assistant] tags in the transcript you pass to the summarizer. Without that, "the user prefers blue" and "the assistant suggested blue" eventually blend into the same statement, and Tier 3 later distills a wrong preference.
A Trap: Language Drift in the Summary
If the system prompt is in English, Haiku will frequently return the summary in English even when the conversation was Japanese. That cascades into Tier 3 distillation noise. Instructing Haiku explicitly to "output a paragraph in the user's language" while keeping the system prompt itself in English fixed it.
Tier 3: Distilling Entities and Intent into JSON with Sonnet
Once at least three episode summaries exist, I run Tier 3 to update the long-range memory snapshot. The point here is structure over prose, so I use tool_use with a strict schema to force JSON output.
Without forcing tool_choice, Sonnet occasionally returns prose instead of using the tool. Making the tool mandatory and listing every field in required guarantees a parseable response and removes a whole class of edge cases from the downstream code. Distillation runs roughly once every 30 turns at a cost of about $0.012 per call, which is negligible.
Merge, Don't Overwrite
My first implementation rebuilt long-term memory from scratch every Tier 3 cycle. That caused stable preferences to flicker — a single new episode could overwrite three episodes of consistent evidence. Passing the previous snapshot as context and instructing Sonnet to "merge prior memory with the new evidence" fixed that, and the stability of the memory snapshots is much better as a result.
Running Compression Asynchronously
The biggest risk of hierarchical summarization is that the compression itself slows down the user. If you call Tier 2 or Tier 3 synchronously before responding, the user pays one to three extra seconds of latency. In an indie app, that shows up in reviews very quickly.
shouldCompressTier2 and shouldCompressTier3 are pure decisions. The actual Claude calls go to Cloudflare Queues from a c.executionCtx.waitUntil block, so the user-facing Worker returns immediately. By the next message, the summaries and updated long-term memory are already in KV.
What Happens When Compression Is Late
Compression failures need an explicit policy. Mine is: if compression has not finished by the time the user sends the next message, fall back to Tier 1 only and accept temporarily blowing the token budget rather than blocking the user. The Worker logs a "compression skipped" event, and an alert fires if the daily skip rate exceeds 5%, which is my signal that the queue is backed up.
Seven Weeks of Production Data
Metric
Before
After
Delta
Avg input_tokens per session
5,840
1,720
−70.5%
Monthly API cost
$480
$145
−69.8%
Avg response latency (p50)
2.1 s
1.7 s
−19%
"Forgot what I said" reviews per week
11
2
−82%
Added compression cost (Haiku + Sonnet)
—
$11.4 / month
—
Why Perceived Quality Improved
The most interesting result was not the cost reduction; it was the drop in "forgot what I said" complaints. Tier 3 was actively preserving entities like "no red or gold" across 30+ turns, and naive trimming could never have done that. From a user perspective the assistant simply seemed to listen better.
A Secondary Latency Win
Time-to-first-token also improved. Requests under 3,000 input tokens landed at about 1,500 ms in my logs, while requests above 6,000 sat at about 2,400 ms. Because compression runs asynchronously, that latency cliff disappeared on the synchronous path. AdMob's ad rendering is sensitive to surrounding response speeds, so faster session pacing translated into slightly higher impressions per session as a side effect.
Prompt Caching on Top of the Three Tiers
If you layer cache_control on top of this architecture, put the long-term memory block in its own cached chunk. The episode list changes too often for caching to help, but the Tier 3 JSON is stable across multiple turns.
Each Tier 2 run appends a new summary, so the episode list invalidates frequently. Putting it under cache_control would just thrash the cache. The Tier 3 JSON is different: my cache hit rate over seven weeks was 71%, and a hit costs roughly one tenth of an uncached input token. That is what brings the API bill down by another tens of dollars per month.
Tier 3 Invalidates the Cache
Every Tier 3 run rewrites the long-term memory block, which immediately invalidates the cache for one turn afterward. I treat that one-turn cost as an accepted overhead. Just be careful not to over-trigger Tier 3 — every distillation pays a re-warm cost, and if DISTILL_TRIGGER is too aggressive, you can lose more on cache thrashing than you gain in fresh memory.
When Hierarchical Summarization Is Not the Right Tool
Three patterns where I would not reach for this design:
1. Short Stateless Q&A
If your product is a one- to three-turn support FAQ assistant, the hierarchy overhead exceeds the actual API spend. A simple ring buffer of the last N turns is fine. My Tier 2 only fires after 12 turns; if most sessions never get there, you are just paying for plumbing.
2. Legally or Medically Sensitive Conversations
Summarization is probabilistic about meaning. In domains where the exact wording of past statements matters — medical history, legal advice — you should keep the raw transcript. My wallpaper app is firmly in a subjective taste domain where this is not a concern, but as soon as you cross into compliance territory, the safer move is to keep history intact.
3. Multi-User Collaborative Sessions
I experimented with a meeting-style chat where multiple users share one session. The Tier 3 schema explodes because every user has independent preferences, and merging them coherently is hard. Per-user state with separate Tier 3 memories worked far better than one shared state.
The Practical Indie-Developer Test
Hierarchical summarization pays off when your sessions grow linearly, when long-running sessions are core to revenue, and when users expect the assistant to remember the conversation. In my situation — solo developer running multiple AdMob-monetized apps with in-app AI features — it dropped API cost by 70% while raising perceived quality, and it is now a permanent part of every chat integration I ship.
What I Want to Try Next
The next iteration I am sketching is a semantic episode boundary detector — picking cuts where the conversation actually shifts topic — and embedding the Tier 3 JSON as a vector so it can be retrieved across multiple sessions. Sharing long-term memory across my app catalog so a user who taught one wallpaper app their color palette does not need to re-teach the next one is the long-term goal. Thanks for reading.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.