← Back to Tutorials
TutorialFor: AI Engineers, ML Engineers, Platform Engineers

How to Cut AI Agent Cost: Prompt Caching and Model Routing

Build a Python support-ticket triage agent on the Claude API, and measure caching, structured outputs, and a Haiku-to-Sonnet cascade at about a third of Sonnet-only cost.

#tutorial#intermediate#anthropic#claude#prompt-caching#cost-optimization#model-routing#python

What you'll build: a Claude triage agent that cuts API cost

This tutorial shows how to reduce Claude API cost in a tool-calling agent loop, using prompt caching, structured outputs, and Haiku-to-Sonnet model routing, with the saving measured at each step. You will end up with a Python triage agent that reads a support ticket, calls two tools, and returns a structured verdict. Along the way you measure what each optimization saves on the same tickets, and you finish with a cascade that sends only high-priority tickets to the larger model.

The level is intermediate. You should be comfortable with Python and with the basics of calling an LLM API; prior experience with tool use is not needed.

Here is what the finished script prints for two tickets. The first escalates to Sonnet and the second stays on Haiku:

text
route=claude-sonnet-5-5 cost=$0.01305fields=PASSpriority=high needs_human=Truepromises_action=Falseroute=claude-haiku-5-5 cost=$0.00038fields=PASSpriority=medium needs_human=Falsepromises_action=Truechecked=2 failed=0

Covers anthropic==0.120.0, claude-sonnet-5-5 and claude-haiku-5-5 as of 2026-10-13. Prices and model names change, so check the current Anthropic pricing page before you copy numbers. Tested on Python 3.13.9 and anthropic==0.120.0 on 2026-10-13. Output from API calls comes from a live run, so your wording will differ. The Step 3 numbers come from a five-ticket baseline run, and the Step 4 routing numbers come from a 20-ticket labeled set listed near the end. Both are tabulated in Step 5. If your loop runs in Claude Code rather than the API, see how to reduce Claude Code token usage.

Prerequisites: Python 3.13 and an Anthropic API key

  • Python 3.13
  • An Anthropic API key with billing enabled. Export it as ANTHROPIC_API_KEY in the shell where you run the code. Each full run of the 20-ticket set in this tutorial costs under one US dollar on the Sonnet-only path and under ten cents with the cascade.
  • Install the pinned SDK:
bash
pip install anthropic==0.120.0

Verify the install:

bash
python -c "import anthropic; print(anthropic.__version__)"

Expected output:

text
0.120.0

Where an agent loop spends tokens: three cost levers

Every ticket runs through the same loop: the model reads the ticket, may call a tool, reads the tool result, and repeats until it returns a verdict. Each model call resends the whole conversation so far, so the input grows with every tool result.

Three levers act on this loop:

  1. Caching reuses the part of the input that never changes, the system prompt and tool schemas, at a fraction of the normal input price.
  2. Structured outputs constrain the final reply to a schema, so the parser never has to recover from prose or a wrapped JSON block.
  3. Routing sends a ticket to a cheaper or a larger model based on something you can observe before paying for the larger one.
d2
direction: down
ticket: "Support ticket" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
model: "Model call\n(messages.create)" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
tools: "Tools\nlookup_customer, search_docs" {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
verdict: "Verdict (JSON)\nstop_reason: end_turn" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
ticket -> model: "1. ticket + history"
model -> tools: "2. tool_use"
tools -> model: "3. tool_result (loop)"
model -> verdict: "4. final text"

Step 1: Build a tool-calling agent loop with Claude in Python

Goal: Build a loop that calls Sonnet 5.5 with two tools and returns a JSON verdict.

Why this step: Every later step changes this loop, so the baseline has to be right first. It must stop on a non-tool response, and it must never leave a tool call without a result. An unanswered tool call makes the next request fail.

d2
direction: down
call: "messages.create()" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
check: "stop_reason == tool_use?" {shape: diamond}
run: "dispatch() each tool_use" {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
done: "return the text reply" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
call -> check: "1. response"
check -> run: "2. yes"
run -> call: "3. append tool_result, call again"
check -> done: "2. no"

Create triage.py:

python
import jsonimport anthropicSONNET = "claude-sonnet-5-5"MAX_TURNS = 6SYSTEM = (    "You triage support tickets for a SaaS billing product. Use the tools to look up "    "the customer and the billing policy before deciding. "    "Then reply with one JSON object "    "only: {\"category\": str, \"priority\": \"low|medium|high\", \"reply_draft\": str, "    "\"needs_human\": bool}."    "\n\nSet needs_human to true only when one of these holds: the customer asks "    "for money back or an exception the policy docs do not cover; the docs have no "    "answer to the question; or the account needs a manual change by staff. "    "Otherwise set it to false, even when the topic sounds sensitive. When the docs "    "state the rule that applies, answer from the rule.")TOOLS = [    {        "name": "lookup_customer",        "description": (            "Look up a customer's plan, account status, and recent invoices "            "by customer id."        ),        "input_schema": {            "type": "object",            "properties": {"customer_id": {"type": "string"}},            "required": ["customer_id"],            "additionalProperties": False,        },    },    {        "name": "search_docs",        "description": "Search the billing policy docs. Returns matching snippets.",        "input_schema": {            "type": "object",            "properties": {"query": {"type": "string"}},            "required": ["query"],            "additionalProperties": False,        },    },]CUSTOMERS = {    "c1": {        "plan": "Pro", "status": "active",        "invoices": [{"id": "INV-101", "amount": 49, "status": "paid"}],    },    "c2": {        "plan": "Team", "status": "past_due",        "invoices": [{"id": "INV-204", "amount": 199, "status": "open"}],    },}DOCS = {    "refund": (        "Refunds: full refund within 14 days of a charge; after 14 days, "        "prorated credit only, approved by a human."    ),    "past due": (        "Past due: accounts are suspended 7 days after an invoice stays open; "        "a retry runs on day 3 and day 5."    ),    "cancel": (        "Cancellation: takes effect at period end; data is kept 30 days; "        "reactivation within 30 days is free."    ),    "upgrade": "Upgrades are prorated immediately; downgrades apply at the next renewal.",}def dispatch(name, args):    if name == "lookup_customer":        return json.dumps(CUSTOMERS.get(args.get("customer_id"), {"error": "not found"}))    if name == "search_docs":        q = args.get("query", "").lower()        hits = [v for k, v in DOCS.items() if any(w in q for w in k.split())]        return json.dumps(hits or ["no matching policy"])    return json.dumps({"error": f"unknown tool {name}"})def triage(client, model, ticket):    messages = [{        "role": "user",        "content": f"Customer id: {ticket['customer_id']}\n\nTicket: {ticket['text']}",    }]    for _ in range(MAX_TURNS):        r = client.messages.create(            model=model, max_tokens=1024, system=SYSTEM, tools=TOOLS, messages=messages,        )        messages.append({"role": "assistant", "content": r.content})        if r.stop_reason != "tool_use":            text = "".join(b.text for b in r.content if b.type == "text")            return json.loads(text)        results = [            {                "type": "tool_result",                "tool_use_id": b.id,                "content": dispatch(b.name, b.input),            }            for b in r.content if b.type == "tool_use"        ]        messages.append({"role": "user", "content": results})    raise RuntimeError(f"no verdict after {MAX_TURNS} turns")if __name__ == "__main__":    client = anthropic.Anthropic()    ticket = {        "customer_id": "c2",        "text": "Our card failed and now the dashboard is locked. What do we do?",    }    print(json.dumps(triage(client, SONNET, ticket), indent=2))

Here is one round trip through triage. The model does not run tools. It returns a tool_use block with an id, a name, and an input. Your code runs dispatch on that block. It then sends back a tool_result in a user message, with the same tool_use_id, and calls the model again. The whole r.content list goes back into messages unchanged, so the model sees its own tool request. CUSTOMERS and DOCS are mock data that stand in for a customer database and a docs search.

Run it:

bash
python triage.py

Expected output:

With no caching and no schema, the wording varies between runs. Check two things: the reply is one JSON object, and it has the keys category, priority, reply_draft, and needs_human.

What just happened: You have a working agent. The prompt asks for JSON in prose, and json.loads is the only thing standing between the model and your code. That is the weak point Step 3 addresses.

Step 2: Add prompt caching for the system prompt and tool schemas with cache_control

Goal: Turn on prompt caching so the system prompt and tool schemas are billed at the cache rate after the first call.

Why this step: Prompt caching stores the part of the input that does not change. The system prompt and tool definitions are identical on every call of every ticket, and without caching you pay the full input rate for them each time. With caching, the first call writes them to the cache and later calls read them back at a fraction of the input price (a twentieth on Sonnet 5.5, a tenth on Haiku 5.5). That only happens for a prefix of at least 512 tokens. A shorter prefix is silently left uncached; the API raises no error, and the only sign is zero cache-write tokens in usage.

d2
direction: down
prefix: "system + tools\n(same on every call)" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
msgs: "messages\n(grows with each tool result)" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
first: "call 1: cache write\n1.25x input price" {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
later: "call 2+: cache read\n0.05x Sonnet, 0.1x Haiku" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
prefix -> msgs: "cached prefix, then new tokens"
prefix -> first: "1. first request"
first -> later: "2. same prefix reused"

Caching prices in this tutorial are per million tokens. A cache write costs 1.25 times the input price. A cache read costs 0.1 times it on Haiku 5.5 and 0.05 times it on Sonnet 5.5, so on Sonnet 5.5 you pay $2.50 per million for a write and $0.10 for a read, against $2.00 for normal input.

Replace the triage function from Step 1 with this version. It adds a cache flag, reads the cache counters, and returns a pair: the verdict and the usage counters.

python
def triage(client, model, ticket, cache=True):    messages = [{        "role": "user",        "content": f"Customer id: {ticket['customer_id']}\n\nTicket: {ticket['text']}",    }]    usage = {"calls": 0, "input": 0, "output": 0, "cache_write": 0, "cache_read": 0}    extra = {"cache_control": {"type": "ephemeral"}} if cache else {}    for _ in range(MAX_TURNS):        r = client.messages.create(            model=model, max_tokens=1024, system=SYSTEM, tools=TOOLS,            messages=messages, **extra,        )        usage["calls"] += 1        usage["input"] += r.usage.input_tokens        usage["output"] += r.usage.output_tokens        usage["cache_write"] += r.usage.cache_creation_input_tokens or 0        usage["cache_read"] += r.usage.cache_read_input_tokens or 0        messages.append({"role": "assistant", "content": r.content})        if r.stop_reason != "tool_use":            text = "".join(b.text for b in r.content if b.type == "text")            return json.loads(text), usage        results = [            {                "type": "tool_result",                "tool_use_id": b.id,                "content": dispatch(b.name, b.input),            }            for b in r.content if b.type == "tool_use"        ]        messages.append({"role": "user", "content": results})    raise RuntimeError(f"no verdict after {MAX_TURNS} turns")

The Step 1 __main__ block still expects one value back from triage. Change its last lines to unpack the pair, so the file prints the verdict and then the usage counters:

python
    verdict, usage = triage(client, SONNET, ticket)    print(json.dumps(verdict, indent=2))    print(usage)

The top-level cache_control argument lets the API place the cache point for you, so the growing conversation is cached along with the system prompt and tools. Each call writes only the tokens it adds, which is why cache_write is small on later calls. A cache entry lasts about five minutes by default, and each hit refreshes it, so a gap longer than that means the next call writes the prefix again.

Run it twice in a row on the same ticket. The second run should show cache reads.

In a live run on 2026-10-13 the Sonnet prefix was already cached from earlier calls, so each call read about 3,480 tokens back and wrote only about 240 tokens of new cache. A first run against an empty cache writes the full prefix instead. If cache_write is zero on your first run, suspect the prefix length before anything else: it is shorter than the minimum.

What just happened: The usage counters now tell you what each call cost. The cache pays off only when the same prefix is reused within the cache lifetime, which is why the measurements below make repeated passes over the same tickets.

Step 3: Get structured outputs from Claude as JSON with output_config.format

Goal: Replace the prose-JSON request with a schema the API enforces on the final reply.

Why this step: In the author's five-ticket baseline run (the first table in Step 5), one run in five did not return parseable JSON. Its raw text was not captured, so this tutorial cannot say which failure mode it was. It does not need to. Schema-constrained output removes the whole class: the final text block is valid JSON that matches the schema, so json.loads cannot fail on a malformed reply. For the validation side of this problem, see harness engineering and LLM output repair.

d2
direction: down
model: "model reply\n(constrained by schema)" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
schema: "output_config.format\njson_schema" {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
text: "final text block\nalways valid JSON" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
parse: "json.loads()" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
schema -> model: "1. constrains decoding"
model -> text: "2. one text block"
text -> parse: "3. parses on every run"

Add this at module level, next to TOOLS:

python
OUTPUT_FORMAT = {    "type": "json_schema",    "schema": {        "type": "object",        "properties": {            "category": {"type": "string"},            "priority": {"type": "string", "enum": ["low", "medium", "high"]},            "reply_draft": {"type": "string"},            "needs_human": {"type": "boolean"},        },        "required": ["category", "priority", "reply_draft", "needs_human"],        "additionalProperties": False,    },}

Then replace the client.messages.create(...) call inside the triage loop with this one, which passes output_config alongside the existing arguments:

python
        r = client.messages.create(            model=model, max_tokens=1024, system=SYSTEM, tools=TOOLS,            messages=messages, output_config={"format": OUTPUT_FORMAT}, **extra,        )

The schema is counted as input on every call, so it is not free. In the measurements it pushed input up about 13 percent on average, and the reduction in calls offset it.

Run it on the same ticket a few times. You should get the same four keys every time.

Check: the reply parses with json.loads on every run, and priority is one of low, medium, or high, because the schema rules out anything else.

The guarantee has two exceptions. A refusal can replace the reply, and a reply that hits max_tokens can be cut short. The script sets max_tokens to 1024 and handles neither case, so a truncated reply makes json.loads raise. In production, check stop_reason before you trust a verdict.

What just happened: The API now validates the reply against the schema before your code sees it, so the parser has stopped being the weak point. What can still fail is the tool loop itself, which raises RuntimeError if it reaches MAX_TURNS without a verdict.

Step 4: Set up model routing, a Haiku 5.5 to Sonnet 5.5 cascade for high-priority tickets

Goal: Run every ticket on Haiku 5.5 first, and call Sonnet 5.5 only when Haiku marks the ticket high priority.

Why this step: On this workload Haiku 5.5 is roughly twenty times cheaper per call than Sonnet 5.5. What has to be established is whether a cheap first pass can decide which tickets need the bigger model. We tested two routing signals on the same 20 tickets. Routing on Haiku's needs_human flag did worse than Haiku alone, because Haiku often reported that no human was needed on tickets that did need one. Routing on Haiku's priority did better, and it is the rule this tutorial uses. For the broader case for a smaller first model, see design patterns for small-model-first systems.

d2
direction: down
ticket: "ticket" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
haiku: "Haiku 5.5 (every ticket)" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
check: "priority == high?" {shape: diamond}
sonnet: "Sonnet 5.5 (escalated)" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
keep: "keep Haiku verdict" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
ticket -> haiku: "1."
haiku -> check: "2."
check -> sonnet: "3. yes"
check -> keep: "3. no"

Add these below triage. They call the Step 3 triage unchanged:

python
HAIKU = "claude-haiku-5-5"PRICES = {  # dollars per million tokens: input, output, cache write, cache read    SONNET: (2.00, 10.00, 2.50, 0.10),  # cache read is 0.05x base for Sonnet 5.5    HAIKU: (0.10, 0.50, 0.125, 0.01),}def cost(model, usage):    pi, po, pw, pr = PRICES[model]    return (usage["input"] * pi + usage["output"] * po            + usage["cache_write"] * pw + usage["cache_read"] * pr) / 1e6def triage_with_cascade(client, ticket):    verdict, usage_h = triage(client, HAIKU, ticket)    total = cost(HAIKU, usage_h)    route = HAIKU    if verdict["priority"] == "high":        verdict, usage_s = triage(client, SONNET, ticket)        total += cost(SONNET, usage_s)        route = SONNET    return verdict, route, total

Add import re to the imports at the top of the file, then add these checks below the cascade functions. The __main__ block in the complete artifact calls them:

python
PRIORITIES = {"low", "medium", "high"}PROMISE = re.compile(    r"process(ing)? (it|the refund)|will process|we'?ll (process|issue)", re.I)def check_verdict(verdict):    """Return the names of the fields that fail a code check. An empty list means pass."""    problems = []    if not isinstance(verdict.get("category"), str) or not verdict["category"].strip():        problems.append("category")    if verdict.get("priority") not in PRIORITIES:        problems.append("priority")    if not isinstance(verdict.get("needs_human"), bool):        problems.append("needs_human")    reply = verdict.get("reply_draft")    if not isinstance(reply, str) or not reply.strip():        problems.append("reply_draft")    return problemsdef promises_action(verdict):    """True when the reply promises an action the script cannot perform,    such as "we will process" a refund. Past tense is not caught."""    return bool(PROMISE.search(verdict.get("reply_draft", "")))

Replace the single-ticket __main__ block from Steps 1 and 2 with this one. It runs two sample tickets, a locked dashboard and a refund request:

python
if __name__ == "__main__":    client = anthropic.Anthropic()    tickets = [        {            "customer_id": "c2",            "text": "Our card failed and now the dashboard is locked. What do we do?",        },        {            "customer_id": "c1",            "text": "Can I get a refund for my charge from yesterday?",        },    ]    failures = 0    for ticket in tickets:        verdict, route, total = triage_with_cascade(client, ticket)        problems = check_verdict(verdict)        failures += bool(problems)        print(f"route={route} cost=${total:.5f}")        print(f"fields={'PASS' if not problems else 'FAIL: ' + ', '.join(problems)}")        print(f"priority={verdict['priority']} needs_human={verdict['needs_human']}")        print(f"promises_action={promises_action(verdict)}")    print(f"checked={len(tickets)} failed={failures}")    raise SystemExit(1 if failures else 0)

Run it. Check the route and the fields line for each ticket. The captured output from that script:

text
route=claude-sonnet-5-5 cost=$0.01305fields=PASSpriority=high needs_human=Truepromises_action=Falseroute=claude-haiku-5-5 cost=$0.00038fields=PASSpriority=medium needs_human=Falsepromises_action=Truechecked=2 failed=0

Haiku rated the first ticket high, so it escalated to Sonnet as the rule predicts. It rated the second medium and kept it.

The script no longer prints the reply text. It checks each verdict in code instead: category and reply_draft must be non-empty strings, priority must be one of low, medium, or high, and needs_human must be a boolean. Both tickets passed in both live runs. The check functions are in the block above and in the complete artifact.

The promises_action line is a separate flag. It fires when the reply promises a future action, such as "we will process" a refund, and since the script has no refund tool, a fired flag means a person should review the reply before it goes out. In the first live run it fired on the second ticket. In the second run the reply was similar but worded differently, and it did not fire. A regular expression only catches the phrasing it names, so treat the flag as a review prompt, not a guarantee.

Step 5: Measure agent cost per ticket: Haiku vs Sonnet vs cascade

Goal: Compare the variants on one ticket set using the cost and the correctness of each verdict.

Why this step: Every optimization so far is a claim about cost, and until it is measured on fixed inputs it is an assumption. The tables below are the author's measurements on the 20-ticket set. This post's code runs only the cascade row, through measure.py. The other rows used scratch variants that this post does not include, so treat them as reported numbers. Input tokens are uncached input only. Cache reads and writes are billed separately and are not in that column. "10 of 10" means two passes over five tickets.

VariantCalls per ticketInput tokensOutput tokensCost per ticketVerdicts that parsed
Sonnet, prompt-only JSON2.82,294456$0.009154 of 5
Sonnet, structured output2.42,599394$0.009145 of 5
Sonnet, structured + caching2.47 uncached389$0.0058210 of 10
Haiku, structured output2.22,358538$0.000505 of 5
Haiku, structured + caching2.06 uncached452$0.0003310 of 10

Caching cut the Sonnet cost by about a third, with a similar saving on Haiku. Structured output left Sonnet's total cost where it was: the schema added input tokens, the run needed fewer calls, and the two cancelled out.

Correctness is a separate measurement. On a set of 20 labeled tickets, with one run each:

StrategyBoth fields rightCost per ticketShare sent to Sonnet
Haiku only15 of 20$0.000480 percent
Sonnet only14 of 20$0.01102100 percent
Cascade on Haiku priority15 to 16 of 20$0.0035025 percent

The cascade cost $0.00350 per ticket in a live run and $0.00324 in a replay. A replay re-scores verdicts saved from earlier runs without calling the API again. The accuracy numbers sit so close together that a one-ticket difference between runs can reorder them.

What this does not show. Twenty tickets is a small sample. The author wrote the labels before running any model, but they encode judgment calls about escalation, and in two of the misses both models chose a different priority than the label. Haiku's needs_human flag also flipped on one borderline ticket between two runs with the same prompt. The cascade is a cost pattern with a measured saving; it is not a proven quality improvement over Haiku alone.

What you can reproduce. Run python measure.py (the script and its setup are in "The 20 labeled support tickets and the measure.py scoring script" at the end of this post). It gives the cascade row for these tickets. To check the other rows you need the no-cache and prompt-only variants, which this post does not include.

Run the variants yourself on a labeled set from your own queue before you rely on the routing rule. The cheapest version of this measurement is a few dozen tickets, one run each, and a table like the one above.

Fixing Claude API errors: AuthenticationError invalid x-api-key and missing credentials

These are the only errors this build hit, with their text copied from the terminal. Failures whose text was not captured are left out.

What you seeCauseFix
ANTHROPIC_API_KEY is set and takes precedence over the SDK's profile / federation auto-discovery; unset ANTHROPIC_API_KEY to use the auto-discovered credential.The SDK found an environment key and also a stored profile. A stored profile is a saved login that ant auth login writes, which the SDK can use instead of the environment variable. The message is informational.Remove the environment variable if you want the stored profile instead.
anthropic.AuthenticationError: Error code: 401 - {'type': 'error', 'error': {'type': 'authentication_error', 'message': 'invalid x-api-key'}}The key was rejected. A rotated or revoked key does this.Create a new key in the console and set it again.
anthropic.AnthropicError: Credentials file not found at ... (profile 'default').No key is set in the environment, so the SDK looked for a stored profile and found none.Set ANTHROPIC_API_KEY and restart the terminal.

How the cascade fits together: triage, dispatch and cost

d2
direction: down
cascade: "triage_with_cascade()" {style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}}
haiku: "triage(HAIKU)\nclaude-haiku-5-5" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
check: "priority == \"high\"?" {shape: diamond}
sonnet: "triage(SONNET)\nclaude-sonnet-5-5" {style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}}
dispatch: "dispatch()\nmock CUSTOMERS / DOCS" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
cost: "cost()\nfrom usage counters" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
cascade -> haiku: "1. every ticket"
haiku -> check: "2. verdict"
check -> sonnet: "3. yes" 
check -> cost: "3. no, keep Haiku"
sonnet -> cost: "4. verdict"
haiku -> dispatch: "tool calls"
sonnet -> dispatch: "tool calls"

Complete Python script: Claude triage agent with caching, JSON output and routing

This is the full file, Steps 1 through 4 combined, and it is the script that produced the captured output in Step 4. Copy this version rather than the Step 1 snippet if you want your output to match. The SYSTEM prompt is the same as in Step 1. What differs from Step 1 is the re import, the check functions, and a __main__ block that runs the two sample tickets and prints the check results instead of the reply. Replace CUSTOMERS and DOCS with your own data.

python
import jsonimport reimport anthropicSONNET = "claude-sonnet-5-5"HAIKU = "claude-haiku-5-5"MAX_TURNS = 6SYSTEM = (    "You triage support tickets for a SaaS billing product. Use the tools to look up "    "the customer and the billing policy before deciding. "    "Then reply with one JSON object "    "only: {\"category\": str, \"priority\": \"low|medium|high\", \"reply_draft\": str, "    "\"needs_human\": bool}."    "\n\nSet needs_human to true only when one of these holds: the customer asks "    "for money back or an exception the policy docs do not cover; the docs have no "    "answer to the question; or the account needs a manual change by staff. "    "Otherwise set it to false, even when the topic sounds sensitive. When the docs "    "state the rule that applies, answer from the rule.")TOOLS = [    {        "name": "lookup_customer",        "description": (            "Look up a customer's plan, account status, and recent invoices "            "by customer id."        ),        "input_schema": {            "type": "object",            "properties": {"customer_id": {"type": "string"}},            "required": ["customer_id"],            "additionalProperties": False,        },    },    {        "name": "search_docs",        "description": "Search the billing policy docs. Returns matching snippets.",        "input_schema": {            "type": "object",            "properties": {"query": {"type": "string"}},            "required": ["query"],            "additionalProperties": False,        },    },]CUSTOMERS = {    "c1": {        "plan": "Pro", "status": "active",        "invoices": [{"id": "INV-101", "amount": 49, "status": "paid"}],    },    "c2": {        "plan": "Team", "status": "past_due",        "invoices": [{"id": "INV-204", "amount": 199, "status": "open"}],    },}DOCS = {    "refund": (        "Refunds: full refund within 14 days of a charge; after 14 days, "        "prorated credit only, approved by a human."    ),    "past due": (        "Past due: accounts are suspended 7 days after an invoice stays open; "        "a retry runs on day 3 and day 5."    ),    "cancel": (        "Cancellation: takes effect at period end; data is kept 30 days; "        "reactivation within 30 days is free."    ),    "upgrade": "Upgrades are prorated immediately; downgrades apply at the next renewal.",}def dispatch(name, args):    if name == "lookup_customer":        return json.dumps(CUSTOMERS.get(args.get("customer_id"), {"error": "not found"}))    if name == "search_docs":        q = args.get("query", "").lower()        hits = [v for k, v in DOCS.items() if any(w in q for w in k.split())]        return json.dumps(hits or ["no matching policy"])    return json.dumps({"error": f"unknown tool {name}"})OUTPUT_FORMAT = {    "type": "json_schema",    "schema": {        "type": "object",        "properties": {            "category": {"type": "string"},            "priority": {"type": "string", "enum": ["low", "medium", "high"]},            "reply_draft": {"type": "string"},            "needs_human": {"type": "boolean"},        },        "required": ["category", "priority", "reply_draft", "needs_human"],        "additionalProperties": False,    },}PRICES = {  # dollars per million tokens: input, output, cache write, cache read    SONNET: (2.00, 10.00, 2.50, 0.10),  # cache read is 0.05x base for Sonnet 5.5    HAIKU: (0.10, 0.50, 0.125, 0.01),}def triage(client, model, ticket, cache=True):    messages = [{        "role": "user",        "content": f"Customer id: {ticket['customer_id']}\n\nTicket: {ticket['text']}",    }]    usage = {"calls": 0, "input": 0, "output": 0, "cache_write": 0, "cache_read": 0}    extra = {"cache_control": {"type": "ephemeral"}} if cache else {}    for _ in range(MAX_TURNS):        r = client.messages.create(            model=model, max_tokens=1024, system=SYSTEM, tools=TOOLS,            messages=messages, output_config={"format": OUTPUT_FORMAT}, **extra,        )        usage["calls"] += 1        usage["input"] += r.usage.input_tokens        usage["output"] += r.usage.output_tokens        usage["cache_write"] += r.usage.cache_creation_input_tokens or 0        usage["cache_read"] += r.usage.cache_read_input_tokens or 0        messages.append({"role": "assistant", "content": r.content})        if r.stop_reason != "tool_use":            text = "".join(b.text for b in r.content if b.type == "text")            return json.loads(text), usage        results = [            {                "type": "tool_result",                "tool_use_id": b.id,                "content": dispatch(b.name, b.input),            }            for b in r.content if b.type == "tool_use"        ]        messages.append({"role": "user", "content": results})    raise RuntimeError(f"no verdict after {MAX_TURNS} turns")def cost(model, usage):    pi, po, pw, pr = PRICES[model]    return (usage["input"] * pi + usage["output"] * po            + usage["cache_write"] * pw + usage["cache_read"] * pr) / 1e6def triage_with_cascade(client, ticket):    verdict, usage_h = triage(client, HAIKU, ticket)    total = cost(HAIKU, usage_h)    route = HAIKU    if verdict["priority"] == "high":        verdict, usage_s = triage(client, SONNET, ticket)        total += cost(SONNET, usage_s)        route = SONNET    return verdict, route, totalPRIORITIES = {"low", "medium", "high"}PROMISE = re.compile(    r"process(ing)? (it|the refund)|will process|we'?ll (process|issue)", re.I)def check_verdict(verdict):    """Return the names of the fields that fail a code check. An empty list means pass."""    problems = []    if not isinstance(verdict.get("category"), str) or not verdict["category"].strip():        problems.append("category")    if verdict.get("priority") not in PRIORITIES:        problems.append("priority")    if not isinstance(verdict.get("needs_human"), bool):        problems.append("needs_human")    reply = verdict.get("reply_draft")    if not isinstance(reply, str) or not reply.strip():        problems.append("reply_draft")    return problemsdef promises_action(verdict):    """True when the reply promises an action the script cannot perform,    such as "we will process" a refund. Past tense is not caught."""    return bool(PROMISE.search(verdict.get("reply_draft", "")))if __name__ == "__main__":    client = anthropic.Anthropic()    tickets = [        {            "customer_id": "c2",            "text": "Our card failed and now the dashboard is locked. What do we do?",        },        {            "customer_id": "c1",            "text": "Can I get a refund for my charge from yesterday?",        },    ]    failures = 0    for ticket in tickets:        verdict, route, total = triage_with_cascade(client, ticket)        problems = check_verdict(verdict)        failures += bool(problems)        print(f"route={route} cost=${total:.5f}")        print(f"fields={'PASS' if not problems else 'FAIL: ' + ', '.join(problems)}")        print(f"priority={verdict['priority']} needs_human={verdict['needs_human']}")        print(f"promises_action={promises_action(verdict)}")    print(f"checked={len(tickets)} failed={failures}")    raise SystemExit(1 if failures else 0)

Where to go next: tune the cascade on your own tickets

  • Add a third tier: route tickets rated low priority to Haiku with a shorter output budget, and measure whether replies get shorter without losing correctness.
  • Replace the in-memory CUSTOMERS dictionary with a real lookup, and measure how much the extra tool latency changes the per-ticket cost.
  • Build a labeled set of 50 tickets from your own queue, and compare the cascade's priority rule against a rule that uses the category field.

The 20 labeled support tickets and the measure.py scoring script

These are the tickets behind the Step 5 numbers, with gold labels written before any model was run. Two labels (t3 and t5) disagree with the needs_human rule in the SYSTEM prompt. The model says yes on t3, because the docs have no answer, and the label says no. The misses on those tickets come from the label, not from a model error.

IdCustomerTicketGold priorityGold needs_human
t1c1Hi, I was charged twice this month. Can I get one back?mediumyes
t2c2Our card failed and now the dashboard is locked. What do we do?highno
t3c3How do I export my data to CSV?lowno
t4c4We want to add 20 seats mid-cycle. What does that cost?mediumyes
t5c5I cancelled last month but want my account back. Is that possible?mediumno
t6c6I want a refund for all the charges from last quarter.mediumyes
t7c7Invoice 500 is unpaid. If it isn't resolved today we are disputing it with our bank.highyes
t8c8If I upgrade to Pro today, do I pay the full month or just the rest of it?lowno
t9c9We already paid this, see the attached bank receipt. Please stop the lockout.highyes
t10c10Under GDPR I want all my personal data deleted. Please confirm when it's done.highyes
t11c2Can you move us from Team to Pro effective today?mediumno
t12c4SSO login is broken and nobody on our team can get in. This is urgent.highyes
t13c3Do you offer a student discount?lowyes
t14c1I want to cancel and get money back for the part of the month I won't use.mediumyes
t15c5I cancelled 45 days ago and want my account back with my data.lowyes
t16c8How do I change the billing email on my account?lowyes
t17c10I'm on the annual plan at $39 a month but the invoice says $49. Please fix it.mediumyes
t18c6I was charged yesterday by mistake. Can I get that charge back?mediumno
t19c9Our card is updated now. Can you retry the charge today instead of waiting?highyes
t20c3Thanks for the CSV help. Is there a row limit on the export?lowyes

The script above defines only c1 and c2. Paste this block into triage.py at module level, below CUSTOMERS. measure.py imports it:

python
CUSTOMERS_EXTRA = {    "c3": {        "plan": "Free",        "status": "active",        "invoices": []    },    "c4": {        "plan": "Enterprise",        "status": "active",        "invoices": [            {                "id": "INV-330",                "amount": 2400,                "status": "paid"            }        ]    },    "c5": {        "plan": "Pro",        "status": "cancelled",        "invoices": [            {                "id": "INV-150",                "amount": 49,                "status": "refunded"            }        ]    },    "c6": {        "plan": "Pro",        "status": "active",        "invoices": [            {                "id": "INV-600",                "amount": 49,                "status": "paid",                "age_days": 90            },            {                "id": "INV-601",                "amount": 49,                "status": "paid",                "age_days": 1            }        ]    },    "c7": {        "plan": "Enterprise",        "status": "active",        "invoices": [            {                "id": "INV-500",                "amount": 12000,                "status": "open",                "overdue_days": 40            }        ]    },    "c8": {        "plan": "Free",        "status": "active",        "invoices": []    },    "c9": {        "plan": "Team",        "status": "past_due",        "invoices": [            {                "id": "INV-900",                "amount": 199,                "status": "open"            }        ]    },    "c10": {        "plan": "Pro",        "status": "active",        "invoices": [            {                "id": "INV-1000",                "amount": 49,                "status": "paid"            }        ]    }}

The loop below runs the cascade over these tickets and scores each verdict against the gold labels. Paste the CUSTOMERS_EXTRA block into triage.py first. Then save the loop as measure.py in the same folder and run python measure.py. Each ticket takes two to four calls per model, and about one ticket in four also goes to Sonnet. At the measured per-ticket costs the full run costs about seven cents with the cascade. The summary line looks like both fields right 15/20, cost per ticket $0.00350. The count moves between runs: earlier runs gave 15 and 16 of 20.

python
# measure.py: run the 20 labeled tickets through the cascade and score them.import anthropicimport triagefrom triage import CUSTOMERS_EXTRA, triage_with_cascadetriage.CUSTOMERS.update(CUSTOMERS_EXTRA)TICKETS = [    ("t1", "c1",        "Hi, I was charged twice this month. Can I get one back?"),    ("t2", "c2",        "Our card failed and now the dashboard is locked. What do we "        "do?"),    ("t3", "c3",        "How do I export my data to CSV?"),    ("t4", "c4",        "We want to add 20 seats mid-cycle. What does that cost?"),    ("t5", "c5",        "I cancelled last month but want my account back. Is that "        "possible?"),    ("t6", "c6",        "I want a refund for all the charges from last quarter."),    ("t7", "c7",        "Invoice 500 is unpaid. If it isn't resolved today we are "        "disputing it with our bank."),    ("t8", "c8",        "If I upgrade to Pro today, do I pay the full month or just "        "the rest of it?"),    ("t9", "c9",        "We already paid this, see the attached bank receipt. Please "        "stop the lockout."),    ("t10", "c10",        "Under GDPR I want all my personal data deleted. Please "        "confirm when it's done."),    ("t11", "c2",        "Can you move us from Team to Pro effective today?"),    ("t12", "c4",        "SSO login is broken and nobody on our team can get in. This "        "is urgent."),    ("t13", "c3",        "Do you offer a student discount?"),    ("t14", "c1",        "I want to cancel and get money back for the part of the "        "month I won't use."),    ("t15", "c5",        "I cancelled 45 days ago and want my account back with my "        "data."),    ("t16", "c8",        "How do I change the billing email on my account?"),    ("t17", "c10",        "I'm on the annual plan at $39 a month but the invoice says "        "$49. Please fix it."),    ("t18", "c6",        "I was charged yesterday by mistake. Can I get that charge "        "back?"),    ("t19", "c9",        "Our card is updated now. Can you retry the charge today "        "instead of waiting?"),    ("t20", "c3",        "Thanks for the CSV help. Is there a row limit on the export?"),]GOLD = {  # (priority, needs_human), written before any model was run    "t1": ("medium", True),    "t2": ("high", False),    "t3": ("low", False),    "t4": ("medium", True),    "t5": ("medium", False),    "t6": ("medium", True),    "t7": ("high", True),    "t8": ("low", False),    "t9": ("high", True),    "t10": ("high", True),    "t11": ("medium", False),    "t12": ("high", True),    "t13": ("low", True),    "t14": ("medium", True),    "t15": ("low", True),    "t16": ("low", True),    "t17": ("medium", True),    "t18": ("medium", False),    "t19": ("high", True),    "t20": ("low", True),}def main():    client = anthropic.Anthropic()    right = 0    cost = 0.0    for tid, customer, text in TICKETS:        verdict, route, c = triage_with_cascade(            client, {"customer_id": customer, "text": text}        )        priority, human = GOLD[tid]        match = verdict["priority"] == priority and verdict["needs_human"] == human        right += match        cost += c        print(f"{tid} route={route} both_right={match}")    print(f"both fields right {right}/{len(TICKETS)}, "          f"cost per ticket ${cost / len(TICKETS):.5f}")if __name__ == "__main__":    main()

References

The prompt-caching, structured-outputs, and pricing pages were fetched on 2026-10-13. The caching page gives the 512-token minimum, the 1.25x write price, and the 0.05x read price on Sonnet 5.5. The pricing page gives the Sonnet 5.5 and Haiku 5.5 rates used in this post; it also says Haiku 5.5 prices prompts over 100,000 tokens higher, a threshold the prompts here do not reach. The structured-outputs page says the schema is enforced except on refusals and when the reply hits max_tokens. The models overview page was not fetched, so the model IDs are unconfirmed beyond the SDK's own examples.


AI Engineering

Follow for more technical deep dives on AI/ML systems, production engineering, and building real-world applications:


Books by Ranjan Kumar

Harness Engineering for Production AI Systems cover

Harness Engineering

The 7 GenAI Architectures cover

The 7 GenAI Architectures

Building Real-World Agentic AI Systems with LangGraph cover

Building Real-World Agentic AI Systems

The ChatML Handbook cover

The ChatML Handbook

The Chat Templates Handbook cover

The Chat Templates Handbook

Comments