Tracking what your coding agent costs you
Your agent already writes a complete record of every session to disk. Twenty lines of shell turns it into a spend report, and one hook keeps it honest.
Most cost advice is about the LLM calls your application makes. This page is about the other bill — the one for the agent writing your code — and it is often the larger of the two, because nobody is measuring it.
The economics are the same either way (what tokens cost and where the money goes is the model). What is different is that you already have the raw data: both major harnesses write every session to disk as JSONL. The full layout is in reading the record of what your agent did; this page turns it into money.
Where the numbers are#
# Claude Code: one directory per project, path-slugified
proj=~/.claude/projects/$(pwd | tr '/' '-')
ls -lt "$proj" | head
# Codex: bucketed by date
ls ~/.codex/sessions/2026/09/04/Assistant records carry a usage object. Check what your version actually writes before trusting a field name:
cat "$proj"/*.jsonl \
| jq -r 'select(.type=="assistant") | .message.usage // empty | keys[]' \
| sort | uniq -cThat one command is the right first move, because the field names differ between versions and providers. Everything below assumes input_tokens, output_tokens, cache_creation_input_tokens and cache_read_input_tokens; adjust to what you actually see.
A spend report in one script#
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
trap 'echo "failed at line $LINENO: $BASH_COMMAND" >&2' ERR
# Prices per 1M tokens. Put YOUR current numbers here — these change.
: "${P_IN:=3.00}" "${P_CACHE_WRITE:=3.75}" "${P_CACHE_READ:=0.30}" "${P_OUT:=15.00}"
usage() { echo "usage: agent-spend.sh [--project DIR] [--days N]" >&2; }
proj_dir="$HOME/.claude/projects/$(pwd | tr '/' '-')"
days=30
while [[ $# -gt 0 ]]; do
case "$1" in
--project) proj_dir="$HOME/.claude/projects/$(echo "$2" | tr '/' '-')"; shift 2 ;;
--days) days="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage; exit 2 ;;
esac
done
[[ -d "$proj_dir" ]] || { echo "no transcripts at $proj_dir" >&2; exit 1; }
since=$(( $(date +%s) - days * 86400 ))
find "$proj_dir" -name '*.jsonl' -newermt "-${days} days" -print0 \
| xargs -0 cat 2>/dev/null \
| jq -r --argjson since "$since" '
select(.type=="assistant")
| .message.usage // empty
| [ (.input_tokens // 0),
(.cache_creation_input_tokens // 0),
(.cache_read_input_tokens // 0),
(.output_tokens // 0) ]
| @tsv' \
| awk -v pin="$P_IN" -v pcw="$P_CACHE_WRITE" -v pcr="$P_CACHE_READ" -v pout="$P_OUT" '
{ i+=$1; cw+=$2; cr+=$3; o+=$4; n++ }
END {
if (n == 0) { print "no assistant turns found"; exit }
cost = (i*pin + cw*pcw + cr*pcr + o*pout) / 1000000
printf "turns %10d\n", n
printf "input %10d\n", i
printf "cache write %10d\n", cw
printf "cache read %10d\n", cr
printf "output %10d\n", o
printf "cache hit rate %9.1f%%\n", (cr / (i + cr + 0.0001)) * 100
printf "cost %10.2f USD\n", cost
printf "per turn %10.4f USD\n", cost / n
}'turns 412
input 184,220
cache write 142,880
cache read 6,940,110
output 118,540
cache hit rate 97.4%
cost 7.61 USD
per turn 0.0185 USDCache hit rate is the number to watch. In a healthy agent session it should be very high, because the conversation prefix is stable and gets reused every turn. If it collapses, something is invalidating the prefix — usually a hook injecting a timestamp, or an MCP server whose tool list changed mid-session.
Which projects and which days cost you#
# cost by project, this month
for d in ~/.claude/projects/*/; do
cost=$(cat "$d"/*.jsonl 2>/dev/null \
| jq -r 'select(.type=="assistant") | .message.usage // empty
| [(.input_tokens//0),(.cache_creation_input_tokens//0),
(.cache_read_input_tokens//0),(.output_tokens//0)] | @tsv' \
| awk '{i+=$1;cw+=$2;cr+=$3;o+=$4} END {printf "%.2f", (i*3+cw*3.75+cr*0.3+o*15)/1e6}')
printf '%8s %s\n' "$cost" "$(basename "$d")"
done | sort -rn | head -10# spend by day, to spot the afternoon that got away from you
cat ~/.claude/projects/*/*.jsonl \
| jq -r 'select(.type=="assistant" and .message.usage)
| [(.timestamp[0:10]),
((.message.usage.input_tokens//0)*3
+ (.message.usage.cache_creation_input_tokens//0)*3.75
+ (.message.usage.cache_read_input_tokens//0)*0.3
+ (.message.usage.output_tokens//0)*15) / 1000000] | @tsv' \
| awk '{s[$1]+=$2} END {for (d in s) printf "%s %8.2f\n", d, s[d]}' \
| sortWatch context grow#
Cost per turn rises through a session, because every turn resends everything before it. A session that started cheap and is now expensive per turn is telling you to start a fresh one.
# tokens per turn over the life of one session — should be flat-ish, not a ramp
jq -r 'select(.type=="assistant") | .message.usage // empty
| (.input_tokens//0) + (.cache_read_input_tokens//0)' \
"$proj_dir/<session-uuid>.jsonl" \
| awk '{ n++; printf "%3d %8d %s\n", n, $1, sprintf("%*s", int($1/2000), "") }' \
| tr ' ' '#' | sed 's/#/ /1;s/#/ /1'A steady climb is normal. A step change means something large entered the context — a big file read, a new MCP server, a pasted log. That is the moment to start a new session, and the reason structuring a repo so an agent can navigate it is cost advice as well as quality advice.
Find what MCP servers are costing you#
Tool definitions sit in every single request. A server you installed and never use is a standing charge.
# which tools have actually been called, ever, in this project
cat "$proj_dir"/*.jsonl \
| jq -r 'select(.type=="assistant") | .message.content[]?
| select(.type=="tool_use") | .name' \
| sort | uniq -c | sort -rnAnything from an mcp__* server that does not appear here is pure overhead. Remove it from .mcp.json and add it back for the session where you need it. /context in Claude Code shows the same thing as a share of the window.
A hook that warns before an expensive turn#
Enforcement rather than reporting. This is a UserPromptSubmit hook — it runs before the model sees anything, and exiting 2 blocks the prompt.
#!/usr/bin/env bash
set -Eeuo pipefail
input=$(cat)
transcript=$(jq -r '.transcript_path // empty' <<<"$input")
[[ -f "$transcript" ]] || exit 0
# Approximate context size = the last turn's input + cache reads.
ctx=$(jq -rs 'map(select(.type=="assistant") | .message.usage // empty) | last
| ((.input_tokens // 0) + (.cache_read_input_tokens // 0))' "$transcript" 2>/dev/null || echo 0)
soft=${CLD_CTX_SOFT:-120000}
hard=${CLD_CTX_HARD:-180000}
if (( ctx > hard )); then
echo "Context is ~${ctx} tokens. Every turn now costs real money and quality is degrading." >&2
echo "Start a fresh session with a summary instead." >&2
exit 2 # blocks the prompt, tells you why
elif (( ctx > soft )); then
echo "<cost-note>Context is ~${ctx} tokens. Prefer reading specific files over broad searches.</cost-note>"
fi
exit 0Two different behaviours from one hook, and they are the right two: past the soft limit it injects a note into the model's context nudging it toward cheaper actions; past the hard limit it refuses the prompt entirely and tells you to start over.
Register it:
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [{ "type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/cost-guard.sh" }] }
]
}
}The four things this tells you
- Cache hit rate. Should be above 90% in a normal session. Below that, something is breaking the prefix.
- Cost per turn, over the session. A ramp means start fresh.
- Unused MCP tools. Standing charge, easy to remove.
- Your worst day. Almost always one runaway loop, and finding it changes how you work more than any config does.
Common questions#
Are these numbers exact?#
No — they are your local reconstruction from the transcript, priced with numbers you typed into the script. Treat them as proportions and trends, which is what you actually need, and reconcile absolute figures against your provider's own billing page. /cost in Claude Code gives an in-session figure without any of this.
Why is cache read so much larger than input?#
Because it is working correctly. Each turn resends the whole conversation, and everything stable in it is served from the cache at a fraction of the price. A high cache-read to input ratio is the healthy state — it is when that ratio collapses that you should look for what changed.
Should I run these on a schedule?#
A weekly report is enough for a single developer. The hook is the part worth having always on, because it acts at the moment the decision is being made rather than a week later.
Does this work with Codex?#
The approach does; the field names differ. Codex writes session_meta, turn_context, event_msg and response_item records under ~/.codex/sessions/YYYY/MM/DD/. Run the keys[] discovery command from the top of this page against one of those files and adjust the jq selectors — the awk and reporting halves are unchanged.
Get the Bash agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Bash. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.