token-tracker
Skill0 starsChanged 5 months agoMITView on GitHub
Set up per-turn token tracking for Claude Code sessions. Installs a Stop hook that automatically prints token consumption (input, output, cache) broken down by each API call step after every Claude response. Also provides full-session and per-interaction report scripts. Use this skill when the user wants to monitor, audit, or understand how many tokens each conversation turn consumes.
2 other projects use a version of this.
Adopted by
Most recently taken up by
- mbrundige/token-trackertheir own version, first seen Jul 21, 2026
- thesaifalitai/claude-setuptheir own version, first seen Feb 27, 2026
Other versions2 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 2
- thesaifalitai/claude-setup★ 9same name
- mbrundige/token-tracker★ 2same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
The file
We split this skill into the rules it gives an agent
1 rule. About 0 are common, 0 are distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 221
Restart Claude Code: after editing settings.json for the hook to activate.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Token Tracker Skill
Instruments a Claude Code session so that after every response, the terminal automatically prints how many API calls were made and how many tokens each one consumed.
What gets installed
| File | Purpose |
token_last_turn.py | Stop hook — prints this turn's breakdown after each response |
token_by_turn.py | Full session report grouped by user message |
Phase 1 — Locate the session directory
Claude Code writes a JSONL transcript for every session under:
# macOS / Linux
~/.claude/projects/<project-slug>/<session-id>.jsonl
# Windows
C:\Users\<user>\.claude\projects\<project-slug>\<session-id>.jsonlFind your project slug (URL-encoded working directory):
ls ~/.claude/projects/ # macOS/Linux
dir "C:\Users\%USERNAME%\.claude\projects\" # WindowsThe most recently modified .jsonl is the active session.
Phase 2 — Install the Stop hook script
Save this file as token_last_turn.py somewhere permanent (e.g. ~/.claude/hooks/):
# token_last_turn.py
import json, sys
from pathlib import Path
from datetime import datetime
# Box-drawing/✍✓ glyphs crash on Windows consoles using a legacy codec (cp936/GBK).
# Force UTF-8 output so the hook never aborts mid-print.
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
PRICE = {"i": 3.00, "o": 15.00, "cc": 3.75, "cr": 0.30} # USD / 1M tokens
def cost(u):
return (u["i"]*PRICE["i"] + u["o"]*PRICE["o"] +
u["cc"]*PRICE["cc"] + u["cr"]*PRICE["cr"]) / 1_000_000
def tool_label(content):
for blk in content:
if isinstance(blk, dict) and blk.get("type") == "tool_use":
name = blk.get("name", "")
inp = blk.get("input", {})
if name in ("Read","Write","Edit","Glob"):
p = inp.get("file_path", inp.get("path", inp.get("pattern","")))
return f"{name}({Path(str(p)).name[:25]})"
if name == "Bash":
return "Bash(" + inp.get("command","")[:35].replace("\n"," ") + ")"
return name
return "→ reply"
# Claude Code passes hook context as JSON on stdin
hook_input = {}
try:
hook_input = json.loads(sys.stdin.read())
except:
pass
session_id = hook_input.get("session_id", "")
# Locate JSONL — prefer session_id match, fall back to most-recently-modified
project_dir = Path(hook_input.get("cwd", "."))
# Walk up to find .claude/projects
for parent in [Path.home() / ".claude" / "projects",
Path("C:/Users") / Path.home().name / ".claude" / "projects"]:
if parent.exists():
# Transcripts live one level down: projects/<project-slug>/<session>.jsonl
# so the glob MUST be recursive (**), not a flat *.jsonl.
candidates = sorted(parent.glob("**/*.jsonl"),
key=lambda f: f.stat().st_mtime, reverse=True)
if session_id:
exact = [c for c in candidates if session_id in c.name]
if exact:
candidates = exact
break
else:
candidates = []
jsonl = candidates[0] if candidates else None
if not jsonl:
sys.exit(0)
# Load records and find last assistant turn
records = []
with open(jsonl, encoding="utf-8", errors="replace") as fh:
for line in fh:
line = line.strip()
if not line: continue
try: records.append(json.loads(line))
except: continue
def is_tool_result(rec):
# Most `type=="user"` records are NOT real prompts — they carry tool_result
# blocks fed back to the model. A turn boundary is a *real* user message.
c = rec.get("message", {}).get("content")
if isinstance(c, list):
return any(isinstance(b, dict) and b.get("type") == "tool_result" for b in c)
return False
last_assts = []
for rec in reversed(records):
if rec.get("isSidechain"): continue
rtype = rec.get("type")
if rtype == "assistant" and rec.get("message", {}).get("usage"):
last_assts.insert(0, rec)
elif rtype == "user" and last_assts and not is_tool_result(rec):
break # reached the real user prompt; stop — we have the whole turn
if not last_assts:
sys.exit(0)
# IMPORTANT: Claude Code logs ONE API response as multiple JSONL records —
# one per content block (thinking / text / each tool_use) — and every record
# repeats the SAME message.id and the SAME usage object. Summing per-record
# therefore multiplies tokens by the block count. Collapse by message.id so
# each real API call is counted exactly once; merge the blocks for the label.
steps = [] # one entry per unique API call, in order
by_id = {}
for a in last_assts:
msg = a["message"]
mid = msg.get("id")
if mid not in by_id:
raw = msg.get("usage", {})
by_id[mid] = {
"u": {"i": raw.get("input_tokens",0),
"o": raw.get("output_tokens",0),
"cc":raw.get("cache_creation_input_tokens",0),
"cr":raw.get("cache_read_input_tokens",0)},
"content": list(msg.get("content", [])),
}
steps.append(mid)
else:
by_id[mid]["content"].extend(msg.get("content", []))
# Aggregate over unique API calls only
total = {"i":0,"o":0,"cc":0,"cr":0}
for mid in steps:
for k in total: total[k] += by_id[mid]["u"][k]
tc = cost(total)
print("\n" + "─"*68)
print(f" Token usage — this turn ({len(steps)} step{'s' if len(steps)>1 else ''})")
print("─"*68)
for si, mid in enumerate(steps, 1):
su = by_id[mid]["u"]
sc = cost(su)
label = tool_label(by_id[mid]["content"])
print(f" step {si:>2}: in={su['i']:,} out={su['o']:,} "
f"cache✍={su['cc']:,} cache✓={su['cr']:,} ${sc:.4f} [{label}]")
print("─"*68)
print(f" TOTAL in={total['i']:,} out={total['o']:,} "
f"cache✍={total['cc']:,} cache✓={total['cr']:,} ${tc:.4f}")
print("─"*68 + "\n")Phase 3 — Register the Stop hook
Edit ~/.claude/settings.json (or C:\Users\<user>\.claude\settings.json) and add:
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python /path/to/token_last_turn.py"
}
]
}
]
}
}On Windows use double-backslashes or forward slashes:
"command": "python \"C:/Users/<user>/.claude/hooks/token_last_turn.py\""Restart Claude Code after editing settings.json for the hook to activate.
Phase 4 — Per-interaction full report (optional)
Save as token_by_turn.py and run manually with python token_by_turn.py:
# token_by_turn.py
import json, sys
from pathlib import Path
from datetime import datetime
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
PRICE = {"i": 3.00, "o": 15.00, "cc": 3.75, "cr": 0.30}
JSONL = Path("~/.claude/projects/<slug>/<session>.jsonl").expanduser()
# ← replace with your actual path, or auto-detect as in token_last_turn.py
def cost(u):
return (u["i"]*PRICE["i"] + u["o"]*PRICE["o"] +
u["cc"]*PRICE["cc"] + u["cr"]*PRICE["cr"]) / 1_000_000
def tool_label(content):
for blk in content:
if isinstance(blk, dict) and blk.get("type") == "tool_use":
name = blk.get("name", "")
inp = blk.get("input", {})
if name in ("Read","Write","Edit","Glob"):
p = inp.get("file_path", inp.get("path", inp.get("pattern","")))
return f"{name}({Path(str(p)).name[:28]})"
if name == "Bash":
return "Bash(" + inp.get("command","")[:40].replace("\n"," ") + ")"
return name
return "→ reply"
records = {}
ordered = []
with open(JSONL, encoding="utf-8", errors="replace") as fh:
for line in fh:
line = line.strip()
if not line: continue
try: rec = json.loads(line)
except: continue
uid = rec.get("uuid")
if uid: records[uid] = rec
ordered.append(rec)
def is_tool_result(rec):
# `type=="user"` records that carry tool_result blocks are NOT real prompts —
# they must not start a new turn, or one response with several tool calls gets
# scattered across phantom turns.
c = rec.get("message", {}).get("content")
if isinstance(c, list):
return any(isinstance(b, dict) and b.get("type") == "tool_result" for b in c)
return False
turns = []
current_user = None
current_assts = []
for rec in ordered:
if rec.get("isSidechain"): continue
rtype = rec.get("type")
if rtype == "user" and not is_tool_result(rec):
if current_user is not None:
turns.append((current_user, current_assts))
current_user = rec; current_assts = []
elif rtype == "assistant" and rec.get("message", {}).get("usage"):
current_assts.append(rec)
if current_user is not None:
turns.append((current_user, current_assts))
SEP = "─" * 112
print(SEP)
print(f"{'#':>4} {'Time':>14} {'Steps':>5} {'In':>7} {'Out':>8} "
f"{'Cache✍':>9} {'Cache✓':>10} {'Cost':>8} Message")
print(SEP)
grand = {"i":0,"o":0,"cc":0,"cr":0}; grand_cost = 0.0; grand_steps = 0
global_seen = set() # an API call counts once for the whole session, never twice
for idx, (urec, assts) in enumerate(turns, 1):
if not assts: continue
# Collapse per-block duplicate records into one entry per real API call.
# Claude Code logs one response as N records (one per content block), each
# repeating the same message.id + usage; counting per-record inflates totals.
calls = []; seen = {}
for a in assts:
m = a["message"]; mid = m.get("id")
if mid in global_seen and mid not in seen:
continue # already attributed to an earlier turn
global_seen.add(mid)
if mid not in seen:
raw = m.get("usage", {})
seen[mid] = {"u": {"i": raw.get("input_tokens",0),
"o": raw.get("output_tokens",0),
"cc":raw.get("cache_creation_input_tokens",0),
"cr":raw.get("cache_read_input_tokens",0)},
"content": list(m.get("content", []))}
calls.append(seen[mid])
else:
seen[mid]["content"].extend(m.get("content", []))
tu = {"i":0,"o":0,"cc":0,"cr":0}
for c in calls:
for k in tu: tu[k] += c["u"][k]
tc = cost(tu)
content = urec.get("message", urec.get("content",""))
if isinstance(content, list):
for blk in content:
if isinstance(blk,dict) and blk.get("type")=="text":
content=blk.get("text",""); break
msg = str(content).replace("\n"," ")[:50]
ts_str = urec.get("timestamp","")
try: ts = datetime.fromisoformat(ts_str.replace("Z","+00:00")).strftime("%m-%d %H:%M")
except: ts = "—"
print(f"{idx:>4} {ts:>14} {len(calls):>5} {tu['i']:>7,} {tu['o']:>8,} "
f"{tu['cc']:>9,} {tu['cr']:>10,} ${tc:>7.4f} {msg}")
for si, c in enumerate(calls, 1):
su = c["u"]
sc = cost(su)
label = tool_label(c["content"])
print(f" step{si:>2} {'':14} {'':5} {su['i']:>7,} {su['o']:>8,} "
f"{su['cc']:>9,} {su['cr']:>10,} ${sc:>7.4f} └─ {label}")
for k in grand: grand[k] += tu[k]
grand_cost += tc; grand_steps += len(calls)
print()
print(SEP)
print(f"{'TOT':>4} {'':14} {grand_steps:>5} {grand['i']:>7,} {grand['o']:>8,} "
f"{grand['cc']:>9,} {grand['cr']:>10,} ${grand_cost:>7.4f}")
print(SEP)
print(f"\nPricing: in=${PRICE['i']}/MTok out=${PRICE['o']}/MTok "
f"cache✍=${PRICE['cc']}/MTok cache✓=${PRICE['cr']}/MTok")Output example
Each step is one real API call (collapsed by message.id), not one content
block — so the per-step numbers differ and the TOTAL equals what Claude Code bills:
────────────────────────────────────────────────────────────────────
Token usage — this turn (3 steps)
────────────────────────────────────────────────────────────────────
step 1: in=4 out=812 cache✍=958 cache✓=63,440 $0.0140 [→ reply]
step 2: in=6 out=193 cache✍=311 cache✓=64,402 $0.0085 [Read(SKILL.md)]
step 3: in=2 out=1,953 cache✍=402 cache✓=131,494 $0.0699 [Bash(python …)]
────────────────────────────────────────────────────────────────────
TOTAL in=12 out=2,958 cache✍=1,671 cache✓=259,336 $0.0924
────────────────────────────────────────────────────────────────────How it works
Claude Code writes a JSONL transcript in real time. Each assistant-type record
contains a message.usage object with four fields:
| Field | Meaning |
input_tokens | Non-cached prompt tokens |
output_tokens | Generated tokens |
cache_creation_input_tokens | Tokens written into the prompt cache this call |
cache_read_input_tokens | Tokens read from the prompt cache (cheap) |
The Stop hook reads the JSONL after each response, finds the assistant records belonging to the last turn, and prints a per-step breakdown.
Important — one API call is logged as many records. Claude Code writes a separate JSONL record for each content block of a response (thinking, text, and every
tool_use), and every one of those records repeats the samemessage.idand the sameusageobject. Summing usage per record therefore multiplies the true token count by the number of blocks (often 3–5×). The scripts collapse records bymessage.idso each real API call is counted exactly once — this is the same deduplication the communityccusagetool performs (dedup bymessage.idis safe here because each id maps to a singlerequestId). The result matches Claude Code's own/costaccounting.
Pricing reference (claude-sonnet-4-6)
| Token type | Price |
| Input | $3.00 / MTok |
| Output | $15.00 / MTok |
| Cache write (5-min ephemeral) | $3.75 / MTok |
| Cache read | $0.30 / MTok |
Cache reads are 10× cheaper than regular input — heavy cache usage is normal and desirable in long sessions.
Checklist
[ ] 1. Identify your project's JSONL path
[ ] 2. Save token_last_turn.py to a permanent location
[ ] 3. Add the Stop hook to ~/.claude/settings.json
[ ] 4. Restart Claude Code
[ ] 5. Send any message — token report prints automatically at the end
[ ] 6. (Optional) Run token_by_turn.py for a full session per-turn tableDoes it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

