agentleFS
sunjingjun99sunjingjun99/skill-token-tracker/SKILL.md

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

  • Other versions2 projects use a version of this.

    Word for word · 0

    None found.

    Edited · 0

    None found.

    Wrote their own version · 2

    1. thesaifalitai/claude-setup★ 9same name
    2. 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
  1. Very specific:

    Restart Claude Code: after editing settings.json for the hook to activate.

    line 221

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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

FilePurpose
token_last_turn.pyStop hook — prints this turn's breakdown after each response
token_by_turn.pyFull 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>.jsonl

Find your project slug (URL-encoded working directory):

ls ~/.claude/projects/          # macOS/Linux
dir "C:\Users\%USERNAME%\.claude\projects\"   # Windows

The 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:

FieldMeaning
input_tokensNon-cached prompt tokens
output_tokensGenerated tokens
cache_creation_input_tokensTokens written into the prompt cache this call
cache_read_input_tokensTokens 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 same message.id and the same usage object. Summing usage per record therefore multiplies the true token count by the number of blocks (often 3–5×). The scripts collapse records by message.id so each real API call is counted exactly once — this is the same deduplication the community ccusage tool performs (dedup by message.id is safe here because each id maps to a single requestId). The result matches Claude Code's own /cost accounting.


Pricing reference (claude-sonnet-4-6)

Token typePrice
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 table

Download now (ZIP)

Does 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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.