Build a cost ledger for Claude Code: dollars per task, in about 90 lines
Claude Code can stream every API call as an OpenTelemetry event. Point it at a local receiver, tag each run with a task name, and you get a table of what each task actually cost and why. About 15 minutes.

Somebody asks what the agent costs per ticket and the honest answer is a shrug. The /usage screen shows the current session. The Console shows the month. Neither says that the migration task burned six times what the bug fix did, or that half of Tuesday’s spend was one subagent re-reading the same directory.
Claude Code can answer that itself. It emits an OpenTelemetry event for every API call, with the cost, the token counts, the cache split and what the call was for. All that’s missing is somewhere to put them. A 48-line Python receiver, a 14-line wrapper script and a 29-line report give you a ledger: one row per task, with the money broken down by where it went.
By the end of this piece there’s a cc-ledger command that runs Claude Code headlessly with a task label, a receiver that writes the events to a file, and a report that prints this:
task usd calls out tok cache hit tool fails
sum-empty 0.0071 9 3346 96% 0
sdk 0.0070
generate_session_title 0.0001
explore-repo 0.0062 7 2370 84% 0
sdk 0.0033
agent:builtin:Explore 0.0029
generate_session_title 0.0001
fix-add 0.0019 5 376 99% 0
sdk 0.0017
generate_session_title 0.0001
That’s real output from three small runs on Haiku in a throwaway repo. The numbers are tiny. The shape is what matters: per-task totals, a cache hit rate, and the split between the main loop and everything else.
What you need
Claude Code (the commands below were run on 2.1.300), Python 3 and a shell. No Docker, no collector, no pip installs. If your team already runs an OpenTelemetry collector, skip the receiver and point the same environment variables at it.
Everything lives in one folder. Call it ~/cc-ledger/bin.
Step 1: A receiver that keeps only what you need
Claude Code exports telemetry over OTLP, the OpenTelemetry wire protocol. It has no default protocol, so you have to pick one: grpc, http/json or http/protobuf. JSON over HTTP is the one you can read with nothing but the standard library, which is why this uses it.
Save this as otel_sink.py:
#!/usr/bin/env python3
"""Tiny OTLP/HTTP (JSON) receiver. Appends every log record as one JSON line."""
import json, sys
from http.server import BaseHTTPRequestHandler, HTTPServer
OUT = sys.argv[1] if len(sys.argv) > 1 else "claude-events.jsonl"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 4318
def value(v):
# OTLP wraps every value: {"stringValue": "x"}, {"intValue": "3"}, {"doubleValue": 0.1}
for key, raw in v.items():
if key == "intValue":
return int(raw)
if key == "arrayValue":
return [value(i) for i in raw.get("values", [])]
return raw
return None
# Identity attributes (user.email, account ids, org id) never reach the file.
DROP = ("user.", "organization.", "ccr.")
def attrs(items):
return {a["key"]: value(a["value"]) for a in items or [] if not a["key"].startswith(DROP)}
class Sink(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
if self.path.endswith("/v1/logs"):
data = json.loads(body)
with open(OUT, "a", encoding="utf-8") as f:
for rl in data.get("resourceLogs", []):
for sl in rl.get("scopeLogs", []):
for rec in sl.get("logRecords", []):
f.write(json.dumps(attrs(rec.get("attributes"))) + "\n")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b"{}")
def log_message(self, *args):
pass
HTTPServer(("127.0.0.1", PORT), Sink).serve_forever()
Two choices in there are deliberate. It listens on 127.0.0.1 only, so nothing else on the network can write to your ledger. And it drops every attribute that identifies a person before anything reaches disk. The docs say user.email is always included when available, and an unfiltered capture of the first run in this piece did contain an email address, an account UUID and an organisation ID on every line. A ledger shared with a team shouldn’t carry those.
Start it in the background:
nohup python3 ~/cc-ledger/bin/otel_sink.py ~/cc-ledger/claude-events.jsonl 4318 >/dev/null 2>&1 &
Port 4318 is the conventional OTLP/HTTP port. Claude Code appends /v1/logs to the endpoint, and the receiver answers any POST ending in that path. Metrics requests, if you ever enable them, get an empty 200 and are ignored.
Step 2: A wrapper that labels each run
The label is what turns a pile of events into a ledger. OTEL_RESOURCE_ATTRIBUTES adds custom attributes to everything Claude Code emits. The docs are strict about the format: no spaces, and special characters must be percent-encoded. A branch name like fix/login is fine, but a label with a space would be mangled, so keep labels to letters, digits, hyphens and slashes.
Save this as cc-ledger and chmod +x it:
#!/usr/bin/env bash
# Usage: cc-ledger <task-label> <claude args...>
# Runs claude with telemetry pointed at the local sink and the task label attached.
set -euo pipefail
label="$1"; shift
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_METRICS_EXPORTER=none
export OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=http/json
export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
export OTEL_LOGS_EXPORT_INTERVAL=1000
export OTEL_RESOURCE_ATTRIBUTES="task=${label}"
claude "$@" </dev/null
sleep 2 # let the last batch of events flush before the wrapper returns
The cost data lives in log events, not metrics, so OTEL_METRICS_EXPORTER=none keeps the metrics pipeline quiet. The logs interval defaults to 5,000 ms. Dropping it to 1,000 ms plus the two-second sleep means a short -p run doesn’t exit before its final batch is sent. Without that, the last request or two of a quick task can go missing.
The </dev/null is there because claude -p otherwise waits three seconds for stdin and prints a warning when none arrives.
Run a task through it:
cd ~/some-repo
~/cc-ledger/bin/cc-ledger fix-login -p "The login test fails on an empty password. Reproduce it, then fix it." --permission-mode acceptEdits
Everything after the label goes to claude untouched, so any flag works: --model, --allowedTools, --max-turns.
Step 3: A report that explains the number
Each api_request event carries the model, an estimated cost_usd, the four token counts and a query_source. This is one real event, with the identity fields already stripped by the receiver:
{
"task": "sum-empty",
"event.name": "api_request",
"event.timestamp": "2026-10-15T07:03:55.180Z",
"prompt.id": "8912a757-b4e5-4ca9-b734-bf21adf1d4ce",
"model": "claude-haiku-5-5",
"input_tokens": 1223,
"output_tokens": 16,
"cache_read_tokens": 0,
"cache_creation_tokens": 0,
"cache_creation_5m_tokens": 0,
"cache_creation_1h_tokens": 0,
"cost_usd": 0.0001303,
"duration_ms": 1132,
"query_source": "generate_session_title",
"effort": "medium"
}
(Some fields trimmed.) Two details are worth knowing. input_tokens excludes cache reads and writes, so the full picture needs all of input_tokens, cache_read_tokens and cache_creation_tokens. And the two cache_creation_* fields, which split cache writes by lifetime, arrived in Claude Code 2.1.300, per its changelog. On older versions they won’t be there, and the report below doesn’t depend on them.
prompt.id is a UUID that links every event produced while processing one user prompt. It’s the key for tracing a single prompt through its API calls and tool calls. For a ledger by task, the task label does the grouping, and prompt.id is what you reach for when one task’s row looks wrong and you want to see which prompt did it.
Save the report as cc-report:
#!/usr/bin/env python3
"""Cost report from the sink's JSONL: one row per task, plus where the money went."""
import json, sys
from collections import defaultdict
path = sys.argv[1] if len(sys.argv) > 1 else "claude-events.jsonl"
tasks = defaultdict(lambda: {"usd": 0.0, "calls": 0, "out": 0, "read": 0, "write": 0, "fresh": 0,
"src": defaultdict(float), "fail": 0})
for line in open(path, encoding="utf-8"):
e = json.loads(line)
t = tasks[e.get("task", "(untagged)")]
if e["event.name"] == "api_request":
t["usd"] += float(e["cost_usd"])
t["calls"] += 1
t["out"] += int(e["output_tokens"])
t["fresh"] += int(e["input_tokens"])
t["read"] += int(e["cache_read_tokens"])
t["write"] += int(e["cache_creation_tokens"])
t["src"][e["query_source"]] += float(e["cost_usd"])
elif e["event.name"] == "tool_result":
t["fail"] += e["success"] != "true"
print(f"{'task':<22}{'usd':>9}{'calls':>7}{'out tok':>9}{'cache hit':>11}{'tool fails':>12}")
for name, t in sorted(tasks.items(), key=lambda kv: -kv[1]["usd"]):
seen = t["read"] + t["write"] + t["fresh"]
hit = f"{t['read'] / seen:.0%}" if seen else "-"
print(f"{name:<22}{t['usd']:>9.4f}{t['calls']:>7}{t['out']:>9}{hit:>11}{t['fail']:>12}")
for src, usd in sorted(t["src"].items(), key=lambda kv: -kv[1]):
print(f" {src:<30}{usd:>9.4f}")
Run python3 ~/cc-ledger/bin/cc-report ~/cc-ledger/claude-events.jsonl and you get the table at the top of this piece. Four columns do the work:
usdis the sum ofcost_usdover the task’s calls, most expensive task first.cache hitis cache reads as a share of everything the model read. Low numbers on a long task mean the cache keeps going cold.tool failscountstool_resultevents wheresuccessisn’t"true". A task that cost a lot and failed tools often is a task where the agent flailed.- The indented rows split the cost by
query_source, which is the part/usagecan’t show you.
Reading the table
In the sample, explore-repo asked for a subagent. Its cost splits into sdk (the main loop) and agent:builtin:Explore (the subagent), with a small generate_session_title line for the background call that names the session. About 47% of that task’s spend came from the subagent. Whether that was a good trade is a judgement call, but now it’s visible instead of buried in a total.
sum-empty is the opposite case: one prompt, nine calls, 3,346 output tokens, all of it in the main loop. The agent wrote a fix and a test file. That’s the most expensive row because it produced the most output, not because anything went wrong.
Those are toy tasks, so don’t read anything into the dollar amounts. On real work the same table shows you things like a single task whose cache hit rate sits at 40% because it ran past the cache lifetime between steps, or a refactor where subagent spend dwarfs the main loop. The cache pricing piece explains why idle gaps matter so much to the bill.
Two caveats on the numbers. First, cost_usd is an estimate. Per the Claude Code cost docs, it’s computed locally from token counts at list price, and the Claude Console usage page is the authority for billing. If your organisation pays contracted rates, an administrator can set a modelPricing managed setting so the figures match, but without it the ledger can differ from a discounted bill. Second, the ledger only sees what runs through the wrapper. Interactive sessions and teammates’ machines are invisible until they export too.
Make it yours
The same events work well beyond a single laptop. A few variations that need no new code:
Label by branch. In a repo, cc-ledger "$(git branch --show-current)" -p ... gives you cost per branch, which is close to cost per pull request. Branch names with slashes are fine; ones with spaces or odd characters aren’t, so slugify first if your convention allows them.
Cap the damage. --max-budget-usd stops a print-mode run once estimated spend reaches the amount, and subagent spend counts toward it. A tiny cap shows the behaviour:
claude -p "Read add.ts and sum.ts and describe them" --model haiku --max-budget-usd 0.0005 </dev/null
It prints:
Error: Exceeded USD budget (0.0005)
The docs warn that spend can pass the cap, so leave headroom rather than setting it to the exact amount you can afford. Put the cap in the wrapper (--max-budget-usd 2.00 as a default) and the ledger tells you afterwards which tasks hit it.
Use the built-in total when that’s enough. claude -p --output-format json returns a total_cost_usd for the run along with the number of turns and a per-model usage block. If all you need is a cost line in a CI log, parse that and skip everything here. The ledger earns its keep when you want the split by source, the cache rate, tool failures, or a history across many runs.
Send it to a real backend. Replace http://127.0.0.1:4318 with your collector’s address and drop the Python receiver. The task attribute arrives on every event, so any backend that groups by attribute can build the same table. Mind that Claude Code’s user.email is always attached when available, so decide what your backend should retain.
Run it unattended. The wrapper works unchanged from cron or CI, which is where a ledger is most useful: nobody is watching those runs. The headless digest Playbook covers authentication and scheduling for that.
What goes wrong
The file contains more than costs. Prompts are redacted by default, and the docs say they stay that way unless OTEL_LOG_USER_PROMPTS=1 is set. Tool parameters need OTEL_LOG_TOOL_DETAILS=1. Leave both unset. The events then show tool names, durations and sizes, which is enough for this report, and nothing about your code or commands.
Events can be dropped. A claude -p run that is killed mid-way loses whatever hadn’t flushed, and the receiver, being a script on localhost, loses events while it’s down. Treat the ledger as a good guide, not an audit log. If you need exact billing, use the Console.
Labels are free text. Two runs labelled fix-login and fix-Login are two rows. Pick a convention (branch name, ticket ID) and stick to it.
The receiver is single-threaded. Python’s HTTPServer handles one request at a time, which is fine for a few concurrent sessions and a batch every second. If you fan out dozens of parallel runs, move to a real collector.
Don’t bother if you only run Claude Code interactively and the /usage screen answers your questions. This is for people running agents in scripts, CI or many tasks a day, where there’s no screen to look at.
First 15 minutes
Create ~/cc-ledger/bin and save the three files. Start the receiver with the nohup line above. Pick a small task in a repo you don’t mind it editing, run it through cc-ledger with a label, and run cc-report. Then run a second task with a subagent in the prompt and compare the query_source rows. If you only do one thing after that, put a --max-budget-usd in the wrapper.