Tracking AI Agent Costs Per Run
Agent cost tracking is the practice of attributing token usage, and the dollars behind it, to a single agent run rather than to your account bill as a whole. If you run agentic loops with tool calls, subagents, or retries, a monthly invoice tells you almost nothing about which run, which tool, or which customer drove the spend. This article walks through a concrete instrumentation pattern: capturing usage per API call, aggregating it across a run, attributing it to tools and subagents, and storing it somewhere you can query.
The core idea is simple and doesn't depend on any particular framework: every model response carries a usage object with token counts, every agent run has a unique ID you control, and every tool call or subagent hop is a boundary where you can snapshot usage. Wire those three things together and you get a ledger, not a guess.
Why per-run cost tracking matters for AI agents
A single chat completion is cheap to reason about: one request, one response, one usage object. An agent run is not. A single user request can trigger a dozen tool calls, a couple of subagent delegations, a retried step after a tool error, and a final summarization pass. Each of those is a separate model call with its own token count. Without per-run agent cost tracking, you're stuck with one of three bad options:
- Divide the monthly bill by the number of runs and call it an average, which hides the 5% of runs that cost 10x more than the rest.
- Sample a handful of runs manually from logs and extrapolate, which misses spikes caused by a bad prompt, a runaway retry loop, or a user who pastes a huge document.
- Wait until the bill is a problem, then start instrumenting after the fact, by which point you've already burned the budget you were trying to protect.
Per-run tracking flips this: every run gets billed to itself the moment it happens. You can then answer real product and engineering questions: which customer segment is expensive, which tool call dominates spend, whether raising effort on a route actually paid for itself, and whether a specific prompt change increased tokens per turn.
What "a run" actually costs
Before writing code, define the unit you're tracking. A "run" is whatever your product treats as one unit of work: one user request handled end to end, one scheduled job execution, one background agent session. Give every run an ID as the very first thing that happens, before any model call:
import uuid
import time
run_id = str(uuid.uuid4())
run_started_at = time.time()Everything downstream, every API call, every tool invocation, every subagent hop, gets tagged with this run_id. That tag is the join key for your entire cost ledger. If you're inside a web request, use the request ID. If you're inside a cron job, generate one at the top of the job. The rule is: one ID per unit of business value delivered, created before the first token is spent.
A run's total cost is the sum of every model call made under that run_id, plus zero for anything that doesn't touch the model (pure tool execution, disk I/O, non-LLM logic is free from a token-cost perspective, even if it has its own infra cost you might track separately).
Instrumenting token usage at the call site
Every Claude API response includes a usage object with the token counts for that call. The fields that matter for cost tracking:
input_tokens: tokens processed at full price, not served from cacheoutput_tokens: tokens generated in the responsecache_creation_input_tokens: tokens written to the prompt cache this call (billed at a write premium)cache_read_input_tokens: tokens served from the prompt cache (billed far cheaper thaninput_tokens)
The total prompt size for a call is input_tokens + cache_creation_input_tokens + cache_read_input_tokens. If you only log input_tokens, a heavily-cached agent loop will look like it costs almost nothing, which is wrong: cache reads still cost money, just less. Always record all four fields.
Here's a minimal wrapper that captures usage on every call and tags it with the run ID:
import anthropic
client = anthropic.Anthropic()
def call_model(run_id: str, step_name: str, **kwargs):
response = client.messages.create(**kwargs)
record_usage(
run_id=run_id,
step_name=step_name,
model=response.model,
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
cache_creation_input_tokens=response.usage.cache_creation_input_tokens or 0,
cache_read_input_tokens=response.usage.cache_read_input_tokens or 0,
stop_reason=response.stop_reason,
)
return responseresponse.model matters because agent loops often mix models: a cheap model for routing or summarization, a stronger model for the hard step. If you don't record which model actually served each call, you can't compute cost correctly later, since different models have different per-token rates.
The TypeScript shape is the same, just camelCase on the client side and identical field names on the wire:
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
async function callModel(runId: string, stepName: string, params: Anthropic.MessageCreateParamsNonStreaming) {
const response = await client.messages.create(params);
recordUsage({
runId,
stepName,
model: response.model,
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
cacheCreationInputTokens: response.usage.cache_creation_input_tokens ?? 0,
cacheReadInputTokens: response.usage.cache_read_input_tokens ?? 0,
stopReason: response.stop_reason,
});
return response;
}Route every model call in your codebase through one of these wrappers. If you have five places that call client.messages.create directly, you have five blind spots. This is the single highest-leverage change: centralize the call site, and cost tracking becomes a byproduct of normal operation instead of a separate project.
Building an agent cost tracking ledger
Once usage events are captured, store them as immutable rows, not as a running total you mutate in place. A running total loses the ability to break spend down by step, tool, or model after the fact. A simple schema:
CREATE TABLE agent_usage_events (
id BIGSERIAL PRIMARY KEY,
run_id TEXT NOT NULL,
step_name TEXT NOT NULL,
model TEXT NOT NULL,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_input_tokens INTEGER NOT NULL DEFAULT 0,
stop_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_agent_usage_run_id ON agent_usage_events (run_id);record_usage becomes a straight insert:
def record_usage(run_id, step_name, model, input_tokens, output_tokens,
cache_creation_input_tokens, cache_read_input_tokens, stop_reason):
db.execute(
"""
INSERT INTO agent_usage_events
(run_id, step_name, model, input_tokens, output_tokens,
cache_creation_input_tokens, cache_read_input_tokens, stop_reason)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""",
(run_id, step_name, model, input_tokens, output_tokens,
cache_creation_input_tokens, cache_read_input_tokens, stop_reason),
)Cost itself is a derived value, not something to store per event. Keep a small rate table you own and update as your own contracted or observed per-token rates change, keyed by model ID and token type:
# Rates are yours to fill in and update: this file is the single place
# a pricing change touches. Do not hardcode rates inline in application code.
RATE_TABLE = {
"claude-opus-4-8": {"input": ..., "output": ..., "cache_write": ..., "cache_read": ...},
"claude-sonnet-5": {"input": ..., "output": ..., "cache_write": ..., "cache_read": ...},
"claude-haiku-4-5": {"input": ..., "output": ..., "cache_write": ..., "cache_read": ...},
}
def cost_for_event(row) -> float:
rate = RATE_TABLE[row.model]
return (
row.input_tokens * rate["input"]
+ row.output_tokens * rate["output"]
+ row.cache_creation_input_tokens * rate["cache_write"]
+ row.cache_read_input_tokens * rate["cache_read"]
)Computing cost at query time instead of write time means a rate correction, or a retroactive contract change, recalculates every historical run without a backfill migration. Query total cost per run:
SELECT run_id, model,
SUM(input_tokens) AS input_tokens,
SUM(output_tokens) AS output_tokens,
SUM(cache_creation_input_tokens) AS cache_write_tokens,
SUM(cache_read_input_tokens) AS cache_read_tokens
FROM agent_usage_events
WHERE run_id = $1
GROUP BY run_id, model;Feed each grouped row through cost_for_event-style math in application code, or replicate the rate table as a SQL CASE expression if you want cost computed inside the database.
Attributing cost to steps, tools, and subagents
step_name is where the real insight lives. Tag each call with what the agent was doing when it made that call: "plan", "tool:search_docs", "tool:run_tests", "summarize", "subagent:code_reviewer". This turns a flat total into a breakdown you can act on.
If you're driving a manual tool-use loop, tag each iteration:
def run_agent(run_id: str, user_message: str, tools: list):
messages = [{"role": "user", "content": user_message}]
while True:
response = call_model(
run_id=run_id,
step_name="agent_turn",
model="claude-opus-4-8",
max_tokens=16000,
tools=tools,
messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return response
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
messages.append({"role": "user", "content": tool_results})Every loop iteration is one agent_turn row. That's already enough to see, per run, how many turns it took and how token usage grew across turns (a common cost leak: context accumulates every iteration, so turn 8 costs far more in input_tokens than turn 1, even though the user only asked one question).
For subagent delegation, whether you're hand-rolling it or using a managed agent platform, propagate the parent run_id down and add a parent_step_name so you can roll subagent cost up into the step that spawned it:
def run_subagent(parent_run_id: str, parent_step: str, task: str):
subagent_run_id = f"{parent_run_id}:{uuid.uuid4()}"
response = call_model(
run_id=parent_run_id, # rolls up into the parent's total
step_name=f"subagent:{parent_step}",
model="claude-sonnet-5", # cheaper model for a bounded subtask
max_tokens=8000,
messages=[{"role": "user", "content": task}],
)
return responseIf you're on a hosted multi-agent session where the platform runs the loop for you, the equivalent signal is delivered on the event stream rather than something you compute yourself: each model inference emits an event carrying a model_usage object with the same four fields (input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens), scoped to that one inference. Sum those per session (or per subagent thread, if the platform exposes per-thread events) exactly the way you'd sum rows in the ledger above: the attribution model doesn't change, only where the usage numbers come from.
Handling streaming, retries, and parallel tool calls
Streaming responses don't hand you a usage object until the stream completes. Use the SDK's final-message helper rather than accumulating usage from deltas yourself:
with client.messages.stream(
model="claude-opus-4-8",
max_tokens=64000,
messages=messages,
) as stream:
for event in stream:
pass # handle text deltas for display
response = stream.get_final_message()
record_usage(run_id, "agent_turn", response.model,
response.usage.input_tokens, response.usage.output_tokens,
response.usage.cache_creation_input_tokens or 0,
response.usage.cache_read_input_tokens or 0,
response.stop_reason)stream.get_final_message() (stream.finalMessage() in TypeScript) assembles the complete message including a correct, final usage object, so you never need to sum per-token deltas by hand.
Retries need their own step name, not a silent overwrite. If a call fails and you retry it, record both attempts as separate rows (step_name="agent_turn" for both is fine, since they share the run_id and both real token spend happened). Never discard a failed-but-billed attempt from the ledger just because the retry succeeded: if a partial response was streamed before an error, or a request completed but you failed to parse the result, tokens were still spent and should still show up in the run's total.
Parallel tool calls (a single assistant turn requesting multiple tools at once) are still one model call with one usage object; the cost is already captured by that single call_model invocation. Don't double-count by also logging usage per tool inside the parallel batch, since the API didn't make separate calls for each tool, only one call that requested several.
Setting budgets and circuit breakers
Once you're tracking cost per run in near-real time, you can enforce a ceiling instead of discovering the overrun after the fact. The simplest circuit breaker is a running total check before each call:
def check_budget(run_id: str, max_cost_usd: float):
spent = get_run_cost_so_far(run_id) # sums agent_usage_events for this run_id
if spent >= max_cost_usd:
raise RunBudgetExceeded(run_id, spent, max_cost_usd)
def run_agent_with_budget(run_id: str, user_message: str, tools: list, max_cost_usd: float):
while True:
check_budget(run_id, max_cost_usd)
response = call_model(run_id=run_id, step_name="agent_turn", ...)
if response.stop_reason != "tool_use":
return response
# ... handle tool calls ...Check the budget before the call that would exceed it, not after, since you can't refund tokens once a request completes. This won't catch a single oversized call that blows straight through the ceiling, but it reliably stops a runaway loop, which is the far more common failure mode: an agent stuck retrying a failing tool call, or looping between two subagents that keep delegating back to each other.
If you're on a platform that supports a native token ceiling for an agentic loop, prefer that over a hand-rolled check when it's available: the model is aware of the countdown as it plans, so it wraps up work gracefully near the limit instead of your circuit breaker cutting it off mid-thought. A hand-rolled budget check like the one above is the portable fallback that works regardless of which surface you're calling.
Dashboards and alerts
You don't need a BI tool to get value out of this ledger. Three queries cover most operational needs.
Cost per run, most expensive first:
SELECT run_id, SUM(input_tokens) AS total_input, SUM(output_tokens) AS total_output
FROM agent_usage_events
GROUP BY run_id
ORDER BY total_output + total_input DESC
LIMIT 20;Cost by step, to find which part of the agent loop dominates spend:
SELECT step_name,
COUNT(*) AS call_count,
SUM(input_tokens) AS total_input,
SUM(output_tokens) AS total_output,
AVG(output_tokens) AS avg_output_per_call
FROM agent_usage_events
WHERE created_at > now() - interval '7 days'
GROUP BY step_name
ORDER BY total_input + total_output DESC;Daily trend, to catch a regression before it becomes a monthly surprise:
SELECT date_trunc('day', created_at) AS day,
SUM(input_tokens + output_tokens + cache_creation_input_tokens + cache_read_input_tokens) AS total_tokens,
COUNT(DISTINCT run_id) AS run_count
FROM agent_usage_events
GROUP BY 1
ORDER BY 1 DESC
LIMIT 30;Wire the daily trend query into an alert (a scheduled job that compares today's total against a trailing 7-day average, and pages or messages you if it's up more than some threshold you set). That single alert catches the two most common cost incidents: a prompt change that quietly doubled token usage per turn, and a bug that put an agent into a retry loop against a flaky tool.
Common pitfalls in agent cost tracking
Logging only `input_tokens` and ignoring cache fields. A cached agent loop can have input_tokens near zero while cache_read_input_tokens is enormous. If your dashboard only sums input_tokens, it will report a heavily-used, heavily-cached agent as nearly free, which is misleading even though caching is genuinely saving money relative to the uncached cost.
Computing cost at write time with a hardcoded rate. Rates and your own negotiated pricing can change, and mistakes in a hardcoded number are expensive to unwind across millions of historical rows. Store raw token counts, keep the rate table separate, and compute cost at query time.
One `run_id` per API call instead of per unit of work. If every model call gets its own run_id, you lose the ability to see the total cost of the multi-step task a user actually experienced. Generate the ID once, at the top of the run, and thread it through every call, tool, and subagent.
Silently dropping failed-attempt usage. A request that errored after streaming partial output, or a tool call that failed after the model call succeeded, still spent tokens. Record the usage event regardless of what happened downstream of the model call.
No step-level tagging. A single "total spend" number tells you that costs are high; it doesn't tell you why. The five extra minutes it takes to pass a step_name string through your call sites pays for itself the first time you need to explain a cost spike.
FAQ
Does the usage object include tokens spent on tool definitions and system prompts? Yes. input_tokens (or cache_creation_input_tokens / cache_read_input_tokens if part of the prompt is cached) reflects everything rendered into the request: tools, system prompt, and message history combined. There's no separate line item breaking those apart in the response; if you need that breakdown, use the token-counting endpoint against each component before assembling the full request.
Can I get usage numbers without making a real, billed request? Yes, for estimation purposes: the token-counting endpoint returns an input_tokens count for a given set of messages, tools, and system prompt without generating a response, so you can estimate cost before sending a real call. It's a separate endpoint from messages.create and doesn't return output tokens, since no output is generated.
How do I track cost when a run falls back from one model to another mid-request? Some platforms support a chain of fallback models on a single request, and the usage accounting in that case is per-attempt: each attempt (including one that was refused and produced no billable output) shows up separately, and only the attempt that actually produced the served response is the one that billed at that model's rates. If you're building your own fallback logic instead (retrying on a different model after a failure), treat it exactly like any other retry: each attempt is its own call_model invocation, tagged with its own model field, so the ledger naturally reflects which model actually served the response.
Should I track cost per user or per organization, not just per run? Yes, but as a rollup, not a replacement. Add a user_id or org_id column to the same agent_usage_events table (or a separate mapping table if runs already carry that metadata elsewhere) and group by it in your dashboard queries. The per-run ledger stays the source of truth; per-user and per-org views are just different GROUP BY clauses over the same rows.
What's the difference between tracking cost and tracking latency or tool-call count? They're complementary, not substitutes. Cost tells you what a run spent; latency and tool-call count tell you why. A run that costs a lot because it made twelve tool calls in a retry storm looks very different in your dashboards from a run that costs a lot because a single call had a huge document in context. Track step_name, call count, and wall-clock duration alongside token counts in the same event table so you can correlate all three when investigating an outlier.
Do I need a database for this, or can I just log to a file? A database is worth it the moment you want to query by run, step, or time range, which is almost immediately. Structured logs (one JSON line per usage event, with the same fields as the SQL schema above) work fine as an interim step and can be loaded into a database or a log analytics tool later. What matters is that every event carries run_id, step_name, model, and the four token fields, regardless of where you store it.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.