Context Engineering

Treating the context window as a budget you spend, not a container you fill.

Introduction

Prompt engineering is about what you say. Context engineering is about what the model can see when you say it, and it is where most production AI systems actually succeed or fail. A million-token window sounds like it makes this a non-problem. It does the opposite: it makes it possible to spend a fortune on latency and tokens without noticing, and it makes the difference between a well-ordered prompt and a careless one worth real money on every single request.

1. The Window Is a Budget

Everything the model considers has to fit in one window, and everything in that window is paid for on every request. Not once: every turn re-sends the whole conversation, because the API is stateless. A tool result you fetched twenty turns ago is still being transmitted, still being processed, and still being billed.

What Competes for the Window

Tool schemasrendered firstSystem promptinstructionsRetrieved docsgrows with recallHistorygrows every turnContext windowa fixed budgetRoom to answerwhat's left over

Figure 1: Output shares the budget with input. A prompt that fills the window leaves the model no room to answer, which surfaces as truncation rather than as an error.

Two of these grow without anyone deciding they should. History grows every turn, and retrieved documents grow whenever someone tunes recall upward. Those are the two that quietly consume a system.

2. Measure Before You Optimise

Token counts are not a function of character count, and they are model specific. Every example below shares one policy document, built here so the numbers reproduce:

# handbook.py - the shared policy document every example in this lesson imports
"""Builds the policy handbook every example in this lesson shares.

A real handbook is a static file you load. This one is generated so the
examples reproduce byte for byte on any machine.
"""

CATEGORIES = [
    "apparel", "footwear", "electronics",
    "small appliances", "furniture", "sporting goods",
]

REGIONS = ["EU", "UK", "US"]

CLAUSE = """\
{n}. Returns and refunds for {category} in {region}

Standard window. A customer in {region} may return unused {category} within 30
days of delivery for a full refund to the original payment method. The item must
carry its original tags and packaging. Refunds are issued within 5 business days
of the warehouse scanning the return.

Final sale. Items marked FINAL SALE at checkout are not returnable. Agents must
not offer a refund, a replacement, or store credit for a final sale item, and
must not promise a manager review. The correct response is to state the policy
and offer a repair quote where the category supports one.

Defects. A {category} item that arrives damaged or faulty is returnable for 12
months regardless of any final sale marking, because a defect claim is a
statutory right and not a courtesy return. Ask for photographs before issuing a
prepaid label.

Exceptions requiring escalation. Orders above 500 in local currency, orders
placed with a corporate account, and any return where the customer cites a
statutory right the agent cannot confirm are escalated to a supervisor.
"""

_sections = [
    CLAUSE.format(n=i, category=category, region=region)
    for i, (category, region) in enumerate(
        ((c, r) for c in CATEGORIES for r in REGIONS), start=1
    )
]

HANDBOOK = (
    "RETURNS POLICY HANDBOOK\n"
    "Internal reference for support agents. Follow it exactly.\n\n"
    + "\n".join(_sections)
)

Now measure it with the API rather than guessing:

# count_tokens.py - what the same document costs on two models
import anthropic

from handbook import HANDBOOK

client = anthropic.Anthropic()

question = "Can I return a final sale item?"

for model in ["claude-haiku-4-5", "claude-sonnet-5"]:
    count = client.messages.count_tokens(
        model=model,
        system=HANDBOOK,
        messages=[{"role": "user", "content": question}],
    )
    ratio = len(HANDBOOK) / count.input_tokens
    print(f"{model:17} input_tokens = {count.input_tokens:5}   chars/token = {ratio:.2f}")

print("handbook characters:", len(HANDBOOK))
Expected Output:
claude-haiku-4-5  input_tokens =  4460   chars/token = 4.42
claude-sonnet-5   input_tokens =  6259   chars/token = 3.15
handbook characters: 19702

Identical bytes, and a 40 percent difference: 4,460 tokens to Haiku 4.5, 6,259 to Sonnet 5. "Model specific" is not a caveat here, it is the headline. A budget computed against one model is simply wrong for another, and the same document lands on opposite sides of a cache threshold depending on which one you send it to.

Do not use tiktoken for Claude. It is OpenAI's local tokenizer library, the one behind most token-counting snippets you will find online, and it is free and instant because it never leaves your machine. That is the appeal and also the trap: the vocabulary is OpenAI's, so against Claude it always undercounts, which is the direction that costs you money. Measured against the handbook above and two other corpora with o200k_base, counting the text alone as count(2x) - count(1x) so the fixed per-request overhead drops out:
CorpustiktokenHaiku 4.5Sonnet 5
English prose (the handbook)4,1214,446 (-7.3%)6,243 (-34.0%)
Python source1,8012,184 (-17.5%)2,844 (-36.7%)
Spanish prose1,7212,120 (-18.8%)2,800 (-38.5%)

The error is smallest on English prose against Haiku and worst everywhere else, reaching nearly 40 percent. Characters per token varies the same way: 4.42 on Haiku 4.5 against 3.15 on Sonnet 5 for one document. Any ratio you carry in your head is a sanity check for one model, never a budget input.

3. Prompt Caching Is an Architectural Constraint

Caching is the single highest-leverage thing in this lesson, and it is usually described as a parameter you add. It is not. It is a constraint on how you order your prompt, and the parameter only pays off if the ordering is right.

The rule is prefix match. The cache key is the exact bytes of the prompt up to each breakpoint. Rendering order is tools, then system, then messages. Any byte that changes invalidates everything after it. So stable content goes first, volatile content goes last, and that is the whole design.

# caching.py - the same handbook, marked as a cacheable prefix
import anthropic

from handbook import HANDBOOK

client = anthropic.Anthropic()

# Mark the stable prefix as cacheable. Everything up to and including this
# block (tools, then system) is what gets cached.
stable_system = [{
    "type": "text",
    "text": HANDBOOK,
    "cache_control": {"type": "ephemeral"},
}]


def ask(question):
    r = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=100,
        system=stable_system,
        messages=[{"role": "user", "content": question}],
    )
    return r.usage


u1 = ask("Can I return a final sale item?")
print("request 1 (cold)")
print("   cache_creation_input_tokens :", u1.cache_creation_input_tokens)
print("   cache_read_input_tokens     :", u1.cache_read_input_tokens)
print("   input_tokens (uncached)     :", u1.input_tokens)

u2 = ask("What about EU cooling-off periods?")
print("request 2 (warm, different question)")
print("   cache_creation_input_tokens :", u2.cache_creation_input_tokens)
print("   cache_read_input_tokens     :", u2.cache_read_input_tokens)
print("   input_tokens (uncached)     :", u2.input_tokens)
Expected Output:
request 1 (cold)
   cache_creation_input_tokens : 4447
   cache_read_input_tokens     : 0
   input_tokens (uncached)     : 13
request 2 (warm, different question)
   cache_creation_input_tokens : 0
   cache_read_input_tokens     : 4447
   input_tokens (uncached)     : 13

Read the numbers. The first request writes 4,447 tokens into the cache. The second request asks a completely different question and reads all 4,447 back, processing just 13 new tokens. Cache reads cost about a tenth of the input price, so the handbook effectively became free from the second request onward.

usage fieldMeaningRelative cost
cache_creation_input_tokensWritten to cache this request1.25x (5 min TTL), 2x (1 hour TTL)
cache_read_input_tokensServed from cache~0.1x
input_tokensProcessed at full price1x

Break-even follows from those multipliers: with the default 5 minute TTL, two requests pay for the write (1.25 + 0.1 against 2.0 uncached). The 1 hour TTL doubles the write cost, so it needs three. Use the long TTL for bursty traffic with gaps, not as a default.

There is a minimum cacheable prefix, and it is not uniform. It is 512 tokens on Opus 5, 1,024 on Sonnet 5, and 4,096 on Haiku 4.5 and Opus 4.6. Below the threshold nothing caches and no error is raised: you simply see cache_creation_input_tokens: 0 forever. The handbook above is 4,447 tokens as Haiku 4.5 counts them, which clears that model's 4,096 floor with little to spare. Sonnet 5 reads the same document as 6,259 tokens against a 1,024 floor, which is the practical reason the threshold has to be checked per model rather than per document.

4. Silent Cache Invalidators

Here is the same handbook and the same two questions, with one difference: a timestamp at the front of the system prompt. It is the kind of line that gets added for debugging and never removed.

# invalidator.py - one line at the front of the prefix, and the cache is gone
import anthropic
from datetime import datetime

from handbook import HANDBOOK

client = anthropic.Anthropic()


def ask_with(system, question):
    r = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=100,
        system=system,
        messages=[{"role": "user", "content": question}],
    )
    return r.usage


def volatile_system():
    # A timestamp at the FRONT of the prefix. Looks harmless. Is not.
    header = f"Current time: {datetime.now().isoformat()}\n\n"
    return [{
        "type": "text",
        "text": header + HANDBOOK,
        "cache_control": {"type": "ephemeral"},
    }]


v1 = ask_with(volatile_system(), "Can I return a final sale item?")
v2 = ask_with(volatile_system(), "What about EU cooling-off periods?")

print("request 1: created =", v1.cache_creation_input_tokens,
      "| read =", v1.cache_read_input_tokens)
print("request 2: created =", v2.cache_creation_input_tokens,
      "| read =", v2.cache_read_input_tokens)
Expected Output:
request 1: created = 4467 | read = 0
request 2: created = 4467 | read = 0

Every request now writes the cache and reads nothing. Same content, same questions, and the caching went from paying for itself to costing 1.25x forever. Nothing warns you: the code is correct, the responses are correct, only the bill changes.

Volatile content first
system = (
    f"Current time: {datetime.now()}\n"
    f"User: {user.name} ({user.id})\n"
    + HANDBOOK
)
The prefix differs on every request and for every user, so nothing is ever shared or reused.
Stable content first
system = [{
    "type": "text",
    "text": HANDBOOK,
    "cache_control": {"type": "ephemeral"},
}]
# time and user go in the user turn,
# AFTER the breakpoint
messages = [{"role": "user", "content":
    f"[{now}] {user.name} asks: {q}"}]
One cache entry shared by every user and every request; only the tail is new.

Audit for these when a cache hit rate is unexpectedly zero:

PatternWhy it breaks the cache
datetime.now() in the system promptPrefix differs on every single request
uuid4() or a request id near the frontSame: every request is byte-unique
json.dumps(d) without sort_keys=TrueA dict assembled in a different order serializes to different bytes
User name or id interpolated into the system promptOne cache entry per user instead of one shared entry
Conditional system sections (if flag: system += ...)Every flag combination is a separate prefix
Tool list built per user or per requestTools render at position zero, so this invalidates everything
Switching models mid-conversationCaches are scoped per model; there is no sharing

The diagnostic is always the same: log cache_read_input_tokens. If it is zero across requests that should share a prefix, diff the rendered bytes of two consecutive requests and the culprit falls out immediately.

5. When It Stops Fitting

Long-running agents eventually approach the window. There are two server-side tools, and they do genuinely different things.

Summarize or Prune

Long historyapproaching the limitCompactionsummarize old turnsContext editingclear old tool resultsMeaning keptdetail lostBulk removedstructure kept

Figure 2: Compaction trades detail for meaning. Context editing drops content it judges finished with. Long agents often use both.

Compaction summarizes earlier context into a compaction block:

# compaction.py - summarizing a 56,000-token conversation server-side
import anthropic

from handbook import HANDBOOK

client = anthropic.Anthropic()

# Nine handbook revisions: enough to cross the trigger on the next request.
messages = []
for i in range(1, 10):
    messages.append({"role": "user", "content": f"Revision {i}:\n\n{HANDBOOK}"})
    messages.append({"role": "assistant", "content": f"Recorded revision {i}."})
messages.append({"role": "user", "content": "What is the final sale rule?"})

before = client.messages.count_tokens(model="claude-sonnet-5", messages=messages)
print("conversation before :", before.input_tokens, "tokens")

response = client.beta.messages.create(
    betas=["compact-2026-01-12"],
    model="claude-sonnet-5",
    max_tokens=4000,
    messages=messages,
    context_management={"edits": [{
        "type": "compact_20260112",
        "trigger": {"type": "input_tokens", "value": 50_000},  # 50,000 is the minimum
    }]},
)

print("content blocks      :", [b.type for b in response.content])
for step in response.usage.iterations:
    print(f"   {step.type:10} input_tokens = {step.input_tokens}")

# CRITICAL: append the whole content list, not just the text. The compaction
# block carries encrypted state, so it cannot be rebuilt from the summary text.
messages.append({"role": "assistant", "content": response.content})
Expected Output:
conversation before : 56373 tokens
content blocks      : ['compaction', 'text']
   compaction input_tokens = 56458
   message    input_tokens = 801

The iterations breakdown is the whole story. The compaction pass read 56,458 tokens and wrote a summary; the request that actually answered the question then ran on 801. The conversation kept going with a fraction of its own history in front of it.

The one mistake everybody makes with compaction: appending only the text of the response instead of the whole content list. The compaction block is not just a summary, it carries an encrypted_content field, so it cannot be reconstructed from text you extracted. Drop it and the next request re-sends the entire history, which looks like the feature doing nothing.

Two constraints worth knowing before you reach for it. The trigger has a floor: trigger.value must be at least 50000. And it is not available on every model, so a request that sets it on Haiku 4.5 is rejected outright rather than ignored.

Context editing clears rather than summarizes:

# context_editing.py - dropping tool results once the loop gets long
import anthropic

from handbook import HANDBOOK

client = anthropic.Anthropic()

TOOLS = [{
    "name": "read_policy",
    "description": "Return the full policy text for one region. The output is large.",
    "input_schema": {
        "type": "object",
        "properties": {"region": {"type": "string"}},
        "required": ["region"],
    },
}]

messages = [{"role": "user", "content":
             "Read the policy for the EU, then the UK, then the US, one at a time. "
             "Then give me the final sale rule in one sentence."}]

for turn in range(1, 6):
    response = client.beta.messages.create(
        betas=["context-management-2025-06-27"],
        model="claude-sonnet-5",
        max_tokens=2000,
        tools=TOOLS,
        messages=messages,
        context_management={"edits": [
            # clear_thinking_20251015 must come FIRST when both are used.
            {"type": "clear_thinking_20251015"},
            {"type": "clear_tool_uses_20250919",
             "trigger": {"type": "input_tokens", "value": 5000},
             "keep": {"type": "tool_uses", "value": 1}},
        ]},
    )

    print(f"turn {turn}: input_tokens = {response.usage.input_tokens:5}")
    for edit in response.context_management.applied_edits:
        cleared = getattr(edit, "cleared_input_tokens", 0)
        print(f"         applied {edit.type} ({cleared} tokens cleared)")

    messages.append({"role": "assistant", "content": response.content})
    calls = [b for b in response.content if b.type == "tool_use"]
    if not calls:
        break

    # A real tool would read a file; this returns a large slice of the handbook.
    messages.append({"role": "user", "content": [
        {"type": "tool_result", "tool_use_id": c.id, "content": HANDBOOK[:6000]}
        for c in calls
    ]})
Expected Output:
turn 1: input_tokens =   478
turn 2: input_tokens =  2542
turn 3: input_tokens =  4672
turn 4: input_tokens =  3036
         applied clear_tool_uses_20250919 (3858 tokens cleared)

The first three turns grow the way any tool loop grows. On the fourth, the input crossed the 5,000-token trigger, the two oldest tool results were dropped, and the request ran on 3,036 tokens instead of the roughly 6,900 it was heading for. The agent still answered: it had already used what those results contained.

Two rules the API enforces with a 400, not a warning. clear_thinking_20251015 must be first in the edits list whenever it is present, and it requires thinking to be enabled or adaptive, which is why the example above runs on Sonnet 5 rather than Haiku 4.5. Reverse the order and you get clear_thinking_20251015 must be the first strategy; use it on a model without thinking and you get requires thinking to be enabled or adaptive.

Reach for editing when a long tool-calling agent is carrying tool output it will never consult again, and for compaction when the conversation itself is the thing that got long. For state that must outlive the session entirely, you want memory rather than either: see the memory tiers in Lesson 13.

6. A Million Tokens Does Not Retire Retrieval

A recurring argument says large windows make RAG obsolete: stop retrieving, just send everything. It is wrong on four counts, and only the first is about capability.

ObjectionDetail
Your corpus is bigger1M tokens is roughly 750,000 words. A modest document store or codebase exceeds that comfortably.
You pay for all of it, every turnSending 500K tokens of context on each request costs the same as sending it once, times the number of turns. Retrieval sends the 2 percent that matters.
Latency scales with inputTime to first token is a function of how much has to be processed. Users notice.
Signal-to-noise still mattersFilling a window with mostly irrelevant documents makes the answer worse, not just slower.

What large windows genuinely changed is the chunking problem. You no longer need to shred documents into 512-token fragments and hope the right one ranks first. Retrieve whole documents, or whole files, and let the model do the locating. That is a real improvement to how you build RAG, not an argument for deleting it. See Lesson 8 for the retrieval mechanics themselves.

Key Takeaways

  • Everything in the window is billed every turn - the API is stateless, so history is re-sent and re-processed on each request.
  • Caching is prefix match - stable content first, volatile content last. The ordering is the design; cache_control only harvests it.
  • A timestamp in the system prompt costs you the entire cache - verified above: 4,447 tokens read per request became 0.
  • Below the minimum prefix, caching silently does nothing - 512 tokens on Opus 5, 1,024 on Sonnet 5, 4,096 on Haiku 4.5. No error, just zeros.
  • Measure with count_tokens, never tiktoken - it is the wrong tokenizer and it undercounts by 7 to 39 percent depending on model and corpus.
  • Token counts are model specific - one document measured 4,460 tokens on Haiku 4.5 and 6,259 on Sonnet 5, so a budget is per model, not per document.
  • Compaction summarizes, context editing prunes - 56,458 tokens became an 801-token follow-up request, and compaction requires appending the full content list back.
  • Large windows improved RAG, they did not replace it - retrieve bigger chunks, not everything.